add controls

This commit is contained in:
Matteo Rosati
2026-01-16 10:51:32 +01:00
parent 0cb5dc9bab
commit 7ccfa0b0ee
4 changed files with 287 additions and 16 deletions

View File

@@ -1,32 +1,56 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { PlayerMap } from "./components/PlayerMap";
import { StatsDashboard } from "./components/StatsDashboard";
import { FilterControls } from "./components/FilterControls";
import { fetchPlexts } from "./api";
import type { ApiResponse } from "./types";
import type { ApiResponse, Plext } from "./types";
import "./index.css";
export function App() {
const [data, setData] = useState<ApiResponse>({ count: 0, plexts: [] });
const [loading, setLoading] = useState(true);
const [timestampFrom, setTimestampFrom] = useState<Date | null>(null);
const [timestampTo, setTimestampTo] = useState<Date | null>(null);
const [playerName, setPlayerName] = useState<string>("all");
const [limit, setLimit] = useState<number>(100);
// Load data with filters
const loadData = async () => {
try {
const params = {
timestamp_from: timestampFrom ? timestampFrom.getTime() : undefined, // Convert to milliseconds
timestamp_to: timestampTo ? timestampTo.getTime() : undefined, // Convert to milliseconds
player_name: playerName !== "all" ? playerName : undefined,
limit,
};
const result = await fetchPlexts(params);
setData(result);
} catch (error) {
console.error("Error loading data:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
const loadData = async () => {
try {
const result = await fetchPlexts();
setData(result);
} catch (error) {
console.error("Error loading data:", error);
} finally {
setLoading(false);
}
};
loadData();
// Refresh data every 60 seconds
const interval = setInterval(loadData, 60000);
return () => clearInterval(interval);
}, []);
}, [timestampFrom, timestampTo, playerName, limit]);
// Extract unique player names from data
const players = useMemo(() => {
const playerSet = new Set<string>();
data.plexts.forEach((plext: Plext) => {
const player = plext.markup.find((item) => item.type === "PLAYER");
if (player && player.plain) {
playerSet.add(player.plain);
}
});
return Array.from(playerSet).sort();
}, [data.plexts]);
return (
<div className="app">
@@ -45,6 +69,25 @@ export function App() {
</div>
) : (
<>
{/* Filter Controls Section */}
<section className="filters-section">
<div className="section-header">
<h2>Filters</h2>
<p>Configure map display parameters</p>
</div>
<FilterControls
timestampFrom={timestampFrom}
onTimestampFromChange={setTimestampFrom}
timestampTo={timestampTo}
onTimestampToChange={setTimestampTo}
playerName={playerName}
onPlayerNameChange={setPlayerName}
limit={limit}
onLimitChange={setLimit}
players={players}
/>
</section>
{/* Map Section */}
<section className="map-section">
<div className="section-header">

View File

@@ -4,12 +4,35 @@ const API_BASE_URL = "http://localhost:7001";
const API_ENDPOINT = "/plexts/from-db";
const AUTH_CREDENTIALS = "root:root";
export async function fetchPlexts(): Promise<ApiResponse> {
export async function fetchPlexts(params?: {
timestamp_from?: number;
timestamp_to?: number;
player_name?: string;
limit?: number;
}): Promise<ApiResponse> {
const headers = new Headers();
headers.set("Authorization", `Basic ${btoa(AUTH_CREDENTIALS)}`);
// Build query string
const searchParams = new URLSearchParams();
if (params?.timestamp_from !== undefined) {
searchParams.append("timestamp_from", params.timestamp_from.toString());
}
if (params?.timestamp_to !== undefined) {
searchParams.append("timestamp_to", params.timestamp_to.toString());
}
if (params?.player_name && params.player_name !== "all") {
searchParams.append("player_name", params.player_name);
}
if (params?.limit !== undefined) {
searchParams.append("limit", params.limit.toString());
}
const queryString = searchParams.toString();
const url = queryString ? `${API_BASE_URL}${API_ENDPOINT}?${queryString}` : `${API_BASE_URL}${API_ENDPOINT}`;
try {
const response = await fetch(`${API_BASE_URL}${API_ENDPOINT}`, {
const response = await fetch(url, {
method: "GET",
headers,
});

View File

@@ -0,0 +1,98 @@
import React, { useState, useEffect } from "react";
interface FilterControlsProps {
timestampFrom: Date | null;
onTimestampFromChange: (date: Date | null) => void;
timestampTo: Date | null;
onTimestampToChange: (date: Date | null) => void;
playerName: string;
onPlayerNameChange: (playerName: string) => void;
limit: number;
onLimitChange: (limit: number) => void;
players: string[];
}
export function FilterControls({
timestampFrom,
onTimestampFromChange,
timestampTo,
onTimestampToChange,
playerName,
onPlayerNameChange,
limit,
onLimitChange,
players,
}: FilterControlsProps) {
const [localLimit, setLocalLimit] = useState(limit);
useEffect(() => {
setLocalLimit(limit);
}, [limit]);
return (
<div className="filter-controls">
<div className="filter-group">
<label className="filter-label">Timestamp From</label>
<input
type="datetime-local"
value={timestampFrom ? timestampFrom.toISOString().slice(0, 16) : ""}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : null;
onTimestampFromChange(date);
}}
className="filter-input"
/>
</div>
<div className="filter-group">
<label className="filter-label">Timestamp To</label>
<input
type="datetime-local"
value={timestampTo ? timestampTo.toISOString().slice(0, 16) : ""}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : null;
onTimestampToChange(date);
}}
className="filter-input"
/>
</div>
<div className="filter-group">
<label className="filter-label">Player Name</label>
<select
value={playerName}
onChange={(e) => onPlayerNameChange(e.target.value)}
className="filter-select"
>
<option value="all">All Players</option>
{players.map((player) => (
<option key={player} value={player}>
{player}
</option>
))}
</select>
</div>
<div className="filter-group">
<label className="filter-label">
Limit: <span className="filter-value">{localLimit}</span>
</label>
<input
type="range"
min="10"
max="1000"
step="10"
value={localLimit}
onChange={(e) => setLocalLimit(parseInt((e.target as HTMLInputElement).value))}
onMouseUp={(e) => onLimitChange(parseInt((e.target as HTMLInputElement).value))}
onTouchEnd={(e) => onLimitChange(parseInt((e.target as HTMLInputElement).value))}
className="filter-slider"
/>
<div className="slider-range">
<span>10</span>
<span>1000</span>
</div>
</div>
</div>
);
}

View File

@@ -271,6 +271,113 @@ body {
}
}
/* Filter Controls */
.filters-section {
margin-bottom: 3rem;
}
.filter-controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
background: rgba(26, 26, 46, 0.8);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 2rem;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-label {
color: #9ca3af;
font-size: 0.9rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.filter-input,
.filter-select {
padding: 0.75rem 1rem;
background: rgba(17, 24, 39, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: #e0e0e0;
font-size: 1rem;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.filter-input:focus,
.filter-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.filter-select option {
background: #1a1a2e;
color: #e0e0e0;
}
.filter-slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: rgba(17, 24, 39, 0.8);
outline: none;
-webkit-appearance: none;
}
.filter-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
transition: background 0.3s ease, transform 0.3s ease;
}
.filter-slider::-webkit-slider-thumb:hover {
background: #2563eb;
transform: scale(1.1);
}
.filter-slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
border: none;
transition: background 0.3s ease, transform 0.3s ease;
}
.filter-slider::-moz-range-thumb:hover {
background: #2563eb;
transform: scale(1.1);
}
.filter-value {
color: #3b82f6;
font-weight: 600;
}
.slider-range {
display: flex;
justify-content: space-between;
color: #6b7280;
font-size: 0.875rem;
margin-top: 0.25rem;
}
/* Leaflet Marker Popup */
.marker-popup {
color: #1a1a2e;