add controls
This commit is contained in:
71
src/App.tsx
71
src/App.tsx
@@ -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">
|
||||
|
||||
27
src/api.ts
27
src/api.ts
@@ -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,
|
||||
});
|
||||
|
||||
98
src/components/FilterControls.tsx
Normal file
98
src/components/FilterControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
src/index.css
107
src/index.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user