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 { PlayerMap } from "./components/PlayerMap";
|
||||||
import { StatsDashboard } from "./components/StatsDashboard";
|
import { StatsDashboard } from "./components/StatsDashboard";
|
||||||
|
import { FilterControls } from "./components/FilterControls";
|
||||||
import { fetchPlexts } from "./api";
|
import { fetchPlexts } from "./api";
|
||||||
import type { ApiResponse } from "./types";
|
import type { ApiResponse, Plext } from "./types";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [data, setData] = useState<ApiResponse>({ count: 0, plexts: [] });
|
const [data, setData] = useState<ApiResponse>({ count: 0, plexts: [] });
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
|
||||||
try {
|
|
||||||
const result = await fetchPlexts();
|
|
||||||
setData(result);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading data:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
// Refresh data every 60 seconds
|
// Refresh data every 60 seconds
|
||||||
const interval = setInterval(loadData, 60000);
|
const interval = setInterval(loadData, 60000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
@@ -45,6 +69,25 @@ export function App() {
|
|||||||
</div>
|
</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 */}
|
{/* Map Section */}
|
||||||
<section className="map-section">
|
<section className="map-section">
|
||||||
<div className="section-header">
|
<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 API_ENDPOINT = "/plexts/from-db";
|
||||||
const AUTH_CREDENTIALS = "root:root";
|
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();
|
const headers = new Headers();
|
||||||
headers.set("Authorization", `Basic ${btoa(AUTH_CREDENTIALS)}`);
|
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 {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}${API_ENDPOINT}`, {
|
const response = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers,
|
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 */
|
/* Leaflet Marker Popup */
|
||||||
.marker-popup {
|
.marker-popup {
|
||||||
color: #1a1a2e;
|
color: #1a1a2e;
|
||||||
|
|||||||
Reference in New Issue
Block a user