Final Update
This commit is contained in:
+7
-2
@@ -76,8 +76,11 @@ def analyze_room_image(image_bytes: bytes):
|
|||||||
|
|
||||||
row_ind, col_ind = linear_sum_assignment(dist_matrix)
|
row_ind, col_ind = linear_sum_assignment(dist_matrix)
|
||||||
|
|
||||||
|
DISTANCE_THRESHOLD = 800.0 # Increased threshold to ensure grouping even if they are far in the camera view
|
||||||
|
|
||||||
for person_idx, chair_idx in zip(row_ind, col_ind):
|
for person_idx, chair_idx in zip(row_ind, col_ind):
|
||||||
distance = float(dist_matrix[person_idx, chair_idx])
|
distance = float(dist_matrix[person_idx, chair_idx])
|
||||||
|
if distance <= DISTANCE_THRESHOLD:
|
||||||
pairs.append({
|
pairs.append({
|
||||||
"person_index": int(person_idx),
|
"person_index": int(person_idx),
|
||||||
"chair_index": int(chair_idx),
|
"chair_index": int(chair_idx),
|
||||||
@@ -86,13 +89,15 @@ def analyze_room_image(image_bytes: bytes):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
is_full = num_people >= num_chairs if num_chairs > 0 else False
|
available_chairs = num_chairs - len(pairs)
|
||||||
|
is_full = available_chairs <= 0 if num_chairs > 0 else False
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"room_status": "full" if is_full else "available",
|
"room_status": "full" if is_full else "available",
|
||||||
"counts": {
|
"counts": {
|
||||||
"people": num_people,
|
"people": num_people,
|
||||||
"chairs": num_chairs
|
"chairs": num_chairs,
|
||||||
|
"available_chairs": available_chairs
|
||||||
},
|
},
|
||||||
"pairs": pairs,
|
"pairs": pairs,
|
||||||
"details": {
|
"details": {
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ def generate_fake_data():
|
|||||||
for loc in UMD_LOCATIONS:
|
for loc in UMD_LOCATIONS:
|
||||||
db_level = get_db_for_time_and_location(hour, loc["id"])
|
db_level = get_db_for_time_and_location(hour, loc["id"])
|
||||||
|
|
||||||
|
# Estimate people based on noise level.
|
||||||
|
# 35dB = ~0 people. Every 1.5 dB above 35 adds ~1 person.
|
||||||
|
base_people = max(0, (db_level - 35) * 1.5)
|
||||||
|
people_count = int(max(0, base_people + random.uniform(-5, 10)))
|
||||||
|
|
||||||
doc = {
|
doc = {
|
||||||
"room_id": loc["id"],
|
"room_id": loc["id"],
|
||||||
"location": {
|
"location": {
|
||||||
@@ -91,6 +96,7 @@ def generate_fake_data():
|
|||||||
"coordinates": [loc["lng"], loc["lat"]]
|
"coordinates": [loc["lng"], loc["lat"]]
|
||||||
},
|
},
|
||||||
"db": round(db_level, 2),
|
"db": round(db_level, 2),
|
||||||
|
"people": people_count,
|
||||||
"date": current_time
|
"date": current_time
|
||||||
}
|
}
|
||||||
docs_to_insert.append(doc)
|
docs_to_insert.append(doc)
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
properties: {
|
properties: {
|
||||||
room_id: room.room_id,
|
room_id: room.room_id,
|
||||||
db: room.db,
|
db: room.db,
|
||||||
|
people: room.people || 0,
|
||||||
name: locName,
|
name: locName,
|
||||||
date: room.date,
|
date: room.date,
|
||||||
},
|
},
|
||||||
@@ -114,6 +115,7 @@
|
|||||||
properties: {
|
properties: {
|
||||||
room_id: loc.id,
|
room_id: loc.id,
|
||||||
db: 0,
|
db: 0,
|
||||||
|
people: 0,
|
||||||
name: loc.name,
|
name: loc.name,
|
||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
@@ -264,6 +266,19 @@
|
|||||||
|
|
||||||
const rawName = props.name.split("\\n")[0].split("\n")[0];
|
const rawName = props.name.split("\\n")[0].split("\n")[0];
|
||||||
|
|
||||||
|
let occStatus = "Quiet";
|
||||||
|
let occColor = "#94e2d5";
|
||||||
|
if (props.people > 60) {
|
||||||
|
occStatus = "Packed";
|
||||||
|
occColor = "#f38ba8";
|
||||||
|
} else if (props.people > 25) {
|
||||||
|
occStatus = "Moderate";
|
||||||
|
occColor = "#f9e2af";
|
||||||
|
} else if (props.people === 0 && props.db === 0) {
|
||||||
|
occStatus = "No Data";
|
||||||
|
occColor = "#888888";
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="px-3 py-2 bg-crust/90 backdrop-blur-md border border-white/10 rounded-xl shadow-[0_0_15px_rgba(0,0,0,0.5)] min-w-[150px] text-text">
|
<div class="px-3 py-2 bg-crust/90 backdrop-blur-md border border-white/10 rounded-xl shadow-[0_0_15px_rgba(0,0,0,0.5)] min-w-[150px] text-text">
|
||||||
<h3 class="font-display font-bold text-sm mb-1 text-white border-b border-white/10 pb-1">${rawName}</h3>
|
<h3 class="font-display font-bold text-sm mb-1 text-white border-b border-white/10 pb-1">${rawName}</h3>
|
||||||
@@ -272,6 +287,10 @@
|
|||||||
<span class="text-xs font-semibold" style="color: ${statusColor}">${status}</span>
|
<span class="text-xs font-semibold" style="color: ${statusColor}">${status}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-subtext0 mt-1">Noise: <span class="font-mono text-white">${props.db > 0 ? props.db.toFixed(1) + " dB" : "N/A"}</span></p>
|
<p class="text-xs text-subtext0 mt-1">Noise: <span class="font-mono text-white">${props.db > 0 ? props.db.toFixed(1) + " dB" : "N/A"}</span></p>
|
||||||
|
<p class="text-xs text-subtext0 mt-1 flex items-center gap-1">
|
||||||
|
Occupancy: <span class="font-mono" style="color: ${occColor}">${props.people}</span>
|
||||||
|
<span class="text-[10px]" style="color: ${occColor}">(${occStatus})</span>
|
||||||
|
</p>
|
||||||
<p class="text-[10px] text-surface2 mt-2 font-mono">Last updated: ${timeStr}</p>
|
<p class="text-[10px] text-surface2 mt-2 font-mono">Last updated: ${timeStr}</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
let Chart: any;
|
let Chart: any;
|
||||||
|
|
||||||
let chartCanvas: HTMLCanvasElement;
|
let chartCanvas: HTMLCanvasElement | undefined = $state();
|
||||||
let chartInstance: any;
|
let chartInstance: any;
|
||||||
|
|
||||||
function closePopup() {
|
function closePopup() {
|
||||||
@@ -46,9 +46,25 @@
|
|||||||
return Math.round(sum / points.length);
|
return Math.round(sum / points.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let currentPeople = $derived.by(() => {
|
||||||
|
if (!mapState.selectedLocation || !mapState.historyData) return 0;
|
||||||
|
const points = mapState.historyData
|
||||||
|
.filter(d => d.room_id === mapState.selectedLocation?.id)
|
||||||
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
if (points.length === 0) return 0;
|
||||||
|
return points[0].people || 0;
|
||||||
|
});
|
||||||
|
|
||||||
let statusColor = $derived(getChartColor(current2hAvg, themeState.isLight, themeState.isColorBlindFriendly));
|
let statusColor = $derived(getChartColor(current2hAvg, themeState.isLight, themeState.isColorBlindFriendly));
|
||||||
let statusLabel = $derived(getStatusLabel(current2hAvg));
|
let statusLabel = $derived(getStatusLabel(current2hAvg));
|
||||||
|
|
||||||
|
let occStatus = $derived.by(() => {
|
||||||
|
if (currentPeople > 60) return { label: "Packed", color: "#f38ba8" };
|
||||||
|
if (currentPeople > 25) return { label: "Moderate", color: "#f9e2af" };
|
||||||
|
if (currentPeople === 0 && current2hAvg === 0) return { label: "No Data", color: "#888888" };
|
||||||
|
return { label: "Quiet", color: "#94e2d5" };
|
||||||
|
});
|
||||||
|
|
||||||
function drawChart() {
|
function drawChart() {
|
||||||
if (!Chart || !chartCanvas || !mapState.selectedLocation) return;
|
if (!Chart || !chartCanvas || !mapState.selectedLocation) return;
|
||||||
if (chartInstance) chartInstance.destroy();
|
if (chartInstance) chartInstance.destroy();
|
||||||
@@ -65,8 +81,8 @@
|
|||||||
const isCB = themeState.isColorBlindFriendly;
|
const isCB = themeState.isColorBlindFriendly;
|
||||||
|
|
||||||
const ctx = chartCanvas.getContext('2d');
|
const ctx = chartCanvas.getContext('2d');
|
||||||
let gradientLine = statusColor;
|
let gradientLine: any = statusColor;
|
||||||
let gradientFill = statusColor + '33';
|
let gradientFill: any = statusColor + '33';
|
||||||
|
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
gradientLine = ctx.createLinearGradient(0, 0, 0, 200);
|
gradientLine = ctx.createLinearGradient(0, 0, 0, 200);
|
||||||
@@ -95,7 +111,7 @@
|
|||||||
borderWidth: 3,
|
borderWidth: 3,
|
||||||
fill: true,
|
fill: true,
|
||||||
segment: {
|
segment: {
|
||||||
borderColor: ctx => getChartColor(ctx.p1.parsed.y, isLight, isCB)
|
borderColor: (ctx: any) => getChartColor(ctx.p1.parsed.y, isLight, isCB)
|
||||||
},
|
},
|
||||||
tension: 0.5,
|
tension: 0.5,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
@@ -145,6 +161,7 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const chartModule = await import('chart.js/auto');
|
const chartModule = await import('chart.js/auto');
|
||||||
|
// @ts-ignore
|
||||||
const chartjsAdapter = await import('chartjs-adapter-date-fns');
|
const chartjsAdapter = await import('chartjs-adapter-date-fns');
|
||||||
Chart = chartModule.default;
|
Chart = chartModule.default;
|
||||||
if (mapState.selectedLocation) drawChart();
|
if (mapState.selectedLocation) drawChart();
|
||||||
@@ -163,11 +180,20 @@
|
|||||||
<Icon icon="mdi:close" class="text-xl" />
|
<Icon icon="mdi:close" class="text-xl" />
|
||||||
</button>
|
</button>
|
||||||
<h2 class="font-display font-semibold text-2xl text-white mb-1">{mapState.selectedLocation.name}</h2>
|
<h2 class="font-display font-semibold text-2xl text-white mb-1">{mapState.selectedLocation.name}</h2>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 mt-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="w-3 h-3 rounded-full shadow-[0_0_8px_currentColor]" style="color: {statusColor}; background-color: {statusColor};"></div>
|
<div class="w-3 h-3 rounded-full shadow-[0_0_8px_currentColor]" style="color: {statusColor}; background-color: {statusColor};"></div>
|
||||||
<span class="text-sm font-medium" style="color: {statusColor}">{statusLabel}</span>
|
<span class="text-sm font-medium" style="color: {statusColor}">{statusLabel}</span>
|
||||||
<span class="text-slate-400 text-sm ml-1">· {current2hAvg} dB Avg (Last 2h)</span>
|
<span class="text-slate-400 text-sm ml-1">· {current2hAvg} dB Avg (Last 2h)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:account-group" class="text-lg" style="color: {occStatus.color}" />
|
||||||
|
<span class="text-sm font-medium" style="color: {occStatus.color}">{occStatus.label}</span>
|
||||||
|
<span class="text-slate-400 text-sm ml-1">· {currentPeople} people currently</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Usually quiet logic -->
|
<!-- Usually quiet logic -->
|
||||||
|
|||||||
@@ -34,6 +34,12 @@
|
|||||||
<span class="absolute left-full ml-4 px-2 py-1 bg-black/80 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none md:block hidden border border-white/10">History</span>
|
<span class="absolute left-full ml-4 px-2 py-1 bg-black/80 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none md:block hidden border border-white/10">History</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Nav Item: Vision -->
|
||||||
|
<a href="/vision" class="relative flex items-center justify-center p-3 rounded-2xl transition-all duration-300 hover:bg-white/10 group {$page.url.pathname === '/vision' ? 'text-neon-blue drop-shadow-[0_0_10px_rgba(0,243,255,0.6)] bg-white/5' : 'text-slate-400 hover:text-white'}">
|
||||||
|
<Icon icon="mdi:camera" class="text-2xl" />
|
||||||
|
<span class="absolute left-full ml-4 px-2 py-1 bg-black/80 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none md:block hidden border border-white/10">Vision</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- Nav Item: Settings -->
|
<!-- Nav Item: Settings -->
|
||||||
<a href="/settings" class="relative flex items-center justify-center p-3 rounded-2xl transition-all duration-300 hover:bg-white/10 group {$page.url.pathname === '/settings' ? 'text-neon-blue drop-shadow-[0_0_10px_rgba(0,243,255,0.6)]' : 'text-slate-400 hover:text-white'}">
|
<a href="/settings" class="relative flex items-center justify-center p-3 rounded-2xl transition-all duration-300 hover:bg-white/10 group {$page.url.pathname === '/settings' ? 'text-neon-blue drop-shadow-[0_0_10px_rgba(0,243,255,0.6)]' : 'text-slate-400 hover:text-white'}">
|
||||||
<Icon icon="mdi:cog-outline" class="text-2xl" />
|
<Icon icon="mdi:cog-outline" class="text-2xl" />
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
|
||||||
|
let videoElement: HTMLVideoElement;
|
||||||
|
let canvasElement: HTMLCanvasElement;
|
||||||
|
let hiddenCanvas: HTMLCanvasElement;
|
||||||
|
let stream: MediaStream | null = null;
|
||||||
|
let intervalId: any;
|
||||||
|
|
||||||
|
let roomStatus = $state('unknown');
|
||||||
|
let peopleCount = $state(0);
|
||||||
|
let chairsCount = $state(0);
|
||||||
|
let availableChairsCount = $state(0);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
hiddenCanvas = document.createElement('canvas');
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.srcObject = stream;
|
||||||
|
videoElement.play();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Camera access denied or unavailable", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalId = setInterval(processFrame, 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (intervalId) clearInterval(intervalId);
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function processFrame() {
|
||||||
|
if (!videoElement || videoElement.readyState !== videoElement.HAVE_ENOUGH_DATA) return;
|
||||||
|
|
||||||
|
const width = videoElement.videoWidth;
|
||||||
|
const height = videoElement.videoHeight;
|
||||||
|
|
||||||
|
hiddenCanvas.width = width;
|
||||||
|
hiddenCanvas.height = height;
|
||||||
|
const ctx = hiddenCanvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
ctx.drawImage(videoElement, 0, 0, width, height);
|
||||||
|
|
||||||
|
hiddenCanvas.toBlob(async (blob) => {
|
||||||
|
if (!blob) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', blob, 'frame.jpg');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8000/api/vision/room-status', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
roomStatus = data.room_status;
|
||||||
|
peopleCount = data.counts?.people || 0;
|
||||||
|
chairsCount = data.counts?.chairs || 0;
|
||||||
|
availableChairsCount = data.counts?.available_chairs || 0;
|
||||||
|
|
||||||
|
drawOverlay(data, width, height);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to process vision frame:", e);
|
||||||
|
}
|
||||||
|
}, 'image/jpeg');
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOverlay(data: any, videoWidth: number, videoHeight: number) {
|
||||||
|
if (!canvasElement) return;
|
||||||
|
|
||||||
|
const rect = canvasElement.getBoundingClientRect();
|
||||||
|
canvasElement.width = rect.width;
|
||||||
|
canvasElement.height = rect.height;
|
||||||
|
|
||||||
|
const ctx = canvasElement.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvasElement.width, canvasElement.height);
|
||||||
|
|
||||||
|
const scaleX = canvasElement.width / videoWidth;
|
||||||
|
const scaleY = canvasElement.height / videoHeight;
|
||||||
|
|
||||||
|
ctx.scale(scaleX, scaleY);
|
||||||
|
|
||||||
|
const people = data.details?.people || [];
|
||||||
|
const chairs = data.details?.chairs || [];
|
||||||
|
const pairs = data.pairs || [];
|
||||||
|
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
|
||||||
|
people.forEach((p: any) => {
|
||||||
|
ctx.strokeStyle = '#1e66f5'; // blue for people
|
||||||
|
ctx.strokeRect(p.box[0], p.box[1], p.box[2] - p.box[0], p.box[3] - p.box[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
chairs.forEach((c: any) => {
|
||||||
|
ctx.strokeStyle = '#40a02b'; // green for chairs
|
||||||
|
ctx.strokeRect(c.box[0], c.box[1], c.box[2] - c.box[0], c.box[3] - c.box[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw connecting lines between pairs
|
||||||
|
ctx.strokeStyle = '#df8e1d'; // orange/yellow
|
||||||
|
ctx.setLineDash([5, 5]);
|
||||||
|
ctx.beginPath();
|
||||||
|
pairs.forEach((pair: any) => {
|
||||||
|
const person = people[pair.person_index];
|
||||||
|
const chair = chairs[pair.chair_index];
|
||||||
|
if (person && chair) {
|
||||||
|
ctx.moveTo(person.centroid[0], person.centroid[1]);
|
||||||
|
ctx.lineTo(chair.centroid[0], chair.centroid[1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]); // Reset line dash
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:resize={() => {
|
||||||
|
// Re-process layout or clear overlay on resize if needed
|
||||||
|
if (canvasElement) {
|
||||||
|
const ctx = canvasElement.getContext('2d');
|
||||||
|
if (ctx) ctx.clearRect(0, 0, canvasElement.width, canvasElement.height);
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<div class="h-full w-full flex flex-col md:flex-row items-center justify-center p-6 gap-6 pt-20 md:pt-6 md:pl-24 overflow-y-auto">
|
||||||
|
<div class="relative w-full max-w-4xl rounded-3xl overflow-hidden glass-panel border border-white/10 shadow-2xl flex-shrink-0">
|
||||||
|
<video bind:this={videoElement} class="w-full h-auto object-cover block" playsinline muted></video>
|
||||||
|
<canvas bind:this={canvasElement} class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full max-w-md glass-panel rounded-3xl border border-white/10 p-6 flex flex-col gap-4 shadow-xl shrink-0">
|
||||||
|
<h2 class="font-display font-semibold text-2xl text-white mb-2 flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:cctv" class="text-neon-blue" /> Live Vision
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center p-4 bg-white/5 rounded-xl border border-white/10">
|
||||||
|
<span class="text-slate-400 font-medium text-sm">Status</span>
|
||||||
|
<span class="font-bold text-lg {roomStatus === 'full' ? 'text-red-400' : roomStatus === 'available' ? 'text-green-400' : 'text-slate-400'} capitalize">
|
||||||
|
{roomStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center p-4 bg-white/5 rounded-xl border border-white/10">
|
||||||
|
<span class="text-slate-400 font-medium text-sm flex items-center gap-2"><Icon icon="mdi:account" /> People</span>
|
||||||
|
<span class="font-bold text-xl text-white">{peopleCount}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center p-4 bg-white/5 rounded-xl border border-white/10">
|
||||||
|
<span class="text-slate-400 font-medium text-sm flex items-center gap-2"><Icon icon="mdi:chair-school" /> Available Chairs</span>
|
||||||
|
<span class="font-bold text-xl text-white">{availableChairsCount} / {chairsCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.glass-panel) {
|
||||||
|
background: var(--color-panel-glass);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user