diff --git a/src/App.tsx b/src/App.tsx index c72a669..4659ff1 100644 --- a/src/App.tsx +++ b/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({ count: 0, plexts: [] }); const [loading, setLoading] = useState(true); + const [timestampFrom, setTimestampFrom] = useState(null); + const [timestampTo, setTimestampTo] = useState(null); + const [playerName, setPlayerName] = useState("all"); + const [limit, setLimit] = useState(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(); + 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 (
@@ -45,6 +69,25 @@ export function App() {
) : ( <> + {/* Filter Controls Section */} +
+
+

Filters

+

Configure map display parameters

+
+ +
+ {/* Map Section */}
diff --git a/src/api.ts b/src/api.ts index 39a358a..b47d844 100644 --- a/src/api.ts +++ b/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 { +export async function fetchPlexts(params?: { + timestamp_from?: number; + timestamp_to?: number; + player_name?: string; + limit?: number; +}): Promise { 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, }); diff --git a/src/components/FilterControls.tsx b/src/components/FilterControls.tsx new file mode 100644 index 0000000..d2800d7 --- /dev/null +++ b/src/components/FilterControls.tsx @@ -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 ( +
+
+ + { + const date = e.target.value ? new Date(e.target.value) : null; + onTimestampFromChange(date); + }} + className="filter-input" + /> +
+ +
+ + { + const date = e.target.value ? new Date(e.target.value) : null; + onTimestampToChange(date); + }} + className="filter-input" + /> +
+ +
+ + +
+ +
+ + 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" + /> +
+ 10 + 1000 +
+
+
+ ); +} diff --git a/src/index.css b/src/index.css index 3fa0840..e49a55d 100644 --- a/src/index.css +++ b/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;