From 64f7791b7af986f48b0eb43b5c522c9c2d542990 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 20:50:50 +0000 Subject: [PATCH] Changes --- src/components/Dashboard.tsx | 122 +++++++++++++++++++ src/components/EveIcon.tsx | 39 +++++++ src/components/FilterBar.tsx | 128 ++++++++++++++++++++ src/components/SearchBar.tsx | 100 ++++++++++++++++ src/components/StatisticsPanel.tsx | 181 +++++++++++++++++++++++++++++ src/hooks/useFilters.ts | 121 +++++++++++++++++++ src/hooks/useGroupCache.ts | 66 +++++++++++ src/hooks/useStatistics.ts | 67 +++++++++++ src/index.css | 150 ++++++++++++++---------- src/lib/api.ts | 56 +++++++++ src/pages/Index.tsx | 11 +- src/types/api.ts | 52 +++++++++ tailwind.config.ts | 30 +++-- 13 files changed, 1041 insertions(+), 82 deletions(-) create mode 100644 src/components/Dashboard.tsx create mode 100644 src/components/EveIcon.tsx create mode 100644 src/components/FilterBar.tsx create mode 100644 src/components/SearchBar.tsx create mode 100644 src/components/StatisticsPanel.tsx create mode 100644 src/hooks/useFilters.ts create mode 100644 src/hooks/useGroupCache.ts create mode 100644 src/hooks/useStatistics.ts create mode 100644 src/lib/api.ts create mode 100644 src/types/api.ts diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx new file mode 100644 index 0000000..3660bea --- /dev/null +++ b/src/components/Dashboard.tsx @@ -0,0 +1,122 @@ +import { SearchBar } from './SearchBar'; +import { FilterBar } from './FilterBar'; +import { StatisticsPanel } from './StatisticsPanel'; +import { useFilters } from '@/hooks/useFilters'; +import { useStatistics, useItemNames, useStatisticsItemIds } from '@/hooks/useStatistics'; +import { useGroupCache } from '@/hooks/useGroupCache'; +import { SearchResult, StatCategory } from '@/types/api'; +import { Loader2 } from 'lucide-react'; + +export function Dashboard() { + const { + filters, + toggleItem, + toggleShip, + toggleSystem, + toggleModule, + toggleGroup, + isItemActive, + clearAllFilters, + } = useFilters(); + + const { data: stats, isLoading, error } = useStatistics(filters); + const itemIds = useStatisticsItemIds(stats); + const { data: itemNames = {} } = useItemNames(itemIds); + const { fetchGroup } = useGroupCache(); + + const handleSearchSelect = (result: SearchResult) => { + if (result.type === 'ship') { + toggleShip(result.id); + } else if (result.type === 'system') { + toggleSystem(result.id); + } else if (result.type === 'module') { + toggleModule(result.id); + } else if (result.type === 'group') { + toggleGroup(result.id); + } + }; + + const handleGroupClick = async (itemId: number) => { + const groupId = await fetchGroup(itemId); + if (groupId !== null) { + toggleGroup(groupId); + } + }; + + const handleItemClick = (itemId: number, category: StatCategory) => { + toggleItem(itemId, category); + }; + + const panels: { title: string; category: StatCategory; showGroupBadge: boolean }[] = [ + { title: 'Ships', category: 'ships', showGroupBadge: false }, + { title: 'Systems', category: 'systemBreakdown', showGroupBadge: false }, + { title: 'High Slot Modules', category: 'highSlotModules', showGroupBadge: true }, + { title: 'Mid Slot Modules', category: 'midSlotModules', showGroupBadge: true }, + { title: 'Low Slot Modules', category: 'lowSlotModules', showGroupBadge: true }, + { title: 'Rigs', category: 'rigs', showGroupBadge: true }, + { title: 'Drones', category: 'drones', showGroupBadge: true }, + ]; + + return ( +
+ {/* Header */} +
+
+
+

+ zkill Analytics +

+ +
+
+
+ +
+ {/* Filter Bar */} + toggleShip(filters.ship!)} + onRemoveSystem={toggleSystem} + onRemoveModule={toggleModule} + onRemoveGroup={toggleGroup} + onClearAll={clearAllFilters} + /> + + {/* Loading State */} + {isLoading && ( +
+ +
+ )} + + {/* Error State */} + {error && ( +
+ Failed to load statistics. Please try again. +
+ )} + + {/* Statistics Panels */} + {stats && ( +
+ {panels.map(({ title, category, showGroupBadge }) => ( + isItemActive(id, category)} + onItemClick={(id) => handleItemClick(id, category)} + onGroupClick={showGroupBadge ? handleGroupClick : undefined} + showGroupBadge={showGroupBadge} + /> + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/EveIcon.tsx b/src/components/EveIcon.tsx new file mode 100644 index 0000000..64d3ae0 --- /dev/null +++ b/src/components/EveIcon.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react'; +import { getEveImageUrl } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +interface EveIconProps { + typeId: number; + size?: number; + className?: string; +} + +export function EveIcon({ typeId, size = 32, className }: EveIconProps) { + const [error, setError] = useState(false); + + if (error) { + return ( +
+ ? +
+ ); + } + + return ( + = 64 ? 64 : 32)} + alt="" + width={size} + height={size} + className={cn("rounded", className)} + onError={() => setError(true)} + loading="lazy" + /> + ); +} diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx new file mode 100644 index 0000000..faf826c --- /dev/null +++ b/src/components/FilterBar.tsx @@ -0,0 +1,128 @@ +import { X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { EveIcon } from './EveIcon'; +import { FilterState, ItemNames } from '@/types/api'; + +interface FilterBarProps { + filters: FilterState; + itemNames: ItemNames; + totalKillmails: number; + onRemoveShip: () => void; + onRemoveSystem: (id: number) => void; + onRemoveModule: (id: number) => void; + onRemoveGroup: (id: number) => void; + onClearAll: () => void; +} + +export function FilterBar({ + filters, + itemNames, + totalKillmails, + onRemoveShip, + onRemoveSystem, + onRemoveModule, + onRemoveGroup, + onClearAll, +}: FilterBarProps) { + const hasFilters = Boolean( + filters.ship || + filters.systems.length > 0 || + filters.modules.length > 0 || + filters.groups.length > 0 + ); + + if (!hasFilters) { + return ( +
+ + {totalKillmails.toLocaleString()} + + killmails total +
+ ); + } + + return ( +
+ + {totalKillmails.toLocaleString()} + + killmails + + | + + {filters.ship && ( + + )} + + {filters.systems.map(id => ( + onRemoveSystem(id)} + /> + ))} + + {filters.modules.map(id => ( + onRemoveModule(id)} + /> + ))} + + {filters.groups.map(id => ( + onRemoveGroup(id)} + showIcon={false} + /> + ))} + + +
+ ); +} + +interface FilterChipProps { + id: number; + name: string; + label: string; + onRemove: () => void; + showIcon?: boolean; +} + +function FilterChip({ id, name, label, onRemove, showIcon = true }: FilterChipProps) { + return ( +
+ {showIcon && } + {label}: + {name} + +
+ ); +} diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx new file mode 100644 index 0000000..d156b0d --- /dev/null +++ b/src/components/SearchBar.tsx @@ -0,0 +1,100 @@ +import { useState, useRef, useEffect } from 'react'; +import { Search, X } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { useSearch } from '@/hooks/useStatistics'; +import { EveIcon } from './EveIcon'; +import { cn } from '@/lib/utils'; +import { SearchResult } from '@/types/api'; + +interface SearchBarProps { + onSelect: (result: SearchResult) => void; +} + +export function SearchBar({ onSelect }: SearchBarProps) { + const [query, setQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const { data: results, isLoading } = useSearch(query); + const containerRef = useRef(null); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleSelect = (result: SearchResult) => { + onSelect(result); + setQuery(''); + setIsOpen(false); + }; + + const typeLabels: Record = { + ship: 'Ship', + system: 'System', + module: 'Module', + group: 'Group', + }; + + return ( +
+
+ + { + setQuery(e.target.value); + setIsOpen(true); + }} + onFocus={() => setIsOpen(true)} + placeholder="Search ships, systems, modules..." + className="pl-9 pr-9 bg-secondary border-border focus:eve-border-glow" + /> + {query && ( + + )} +
+ + {isOpen && query.length >= 2 && ( +
+ {isLoading && ( +
+ Searching... +
+ )} + + {!isLoading && results?.length === 0 && ( +
+ No results found +
+ )} + + {!isLoading && results?.map((result) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/StatisticsPanel.tsx b/src/components/StatisticsPanel.tsx new file mode 100644 index 0000000..678bbae --- /dev/null +++ b/src/components/StatisticsPanel.tsx @@ -0,0 +1,181 @@ +import { useState } from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { ItemCount, ItemNames, StatCategory } from '@/types/api'; +import { EveIcon } from './EveIcon'; +import { cn } from '@/lib/utils'; +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Cell, +} from 'recharts'; + +interface StatisticsPanelProps { + title: string; + items: ItemCount[]; + itemNames: ItemNames; + category: StatCategory; + isItemActive: (id: number) => boolean; + onItemClick: (id: number) => void; + onGroupClick?: (itemId: number) => void; + showGroupBadge?: boolean; +} + +export function StatisticsPanel({ + title, + items, + itemNames, + category, + isItemActive, + onItemClick, + onGroupClick, + showGroupBadge = false, +}: StatisticsPanelProps) { + const [isOpen, setIsOpen] = useState(true); + + const totalCount = items.reduce((sum, item) => sum + item.count, 0); + const topItems = items.slice(0, 10); + + const chartData = topItems.map(item => ({ + name: itemNames[item.itemId] || `#${item.itemId}`, + count: item.count, + id: item.itemId, + })); + + return ( +
+ + + {isOpen && ( +
+ {/* Bar Chart */} + {topItems.length > 0 && ( +
+ + + + + [value.toLocaleString(), 'Count']} + /> + + {chartData.map((entry) => ( + onItemClick(entry.id)} + /> + ))} + + + +
+ )} + + {/* Table */} +
+ {items.map((item) => { + const isActive = isItemActive(item.itemId); + const percentage = totalCount > 0 ? (item.count / totalCount) * 100 : 0; + const name = itemNames[item.itemId] || `#${item.itemId}`; + + return ( +
onItemClick(item.itemId)} + className={cn( + "flex items-center gap-2 p-2 rounded cursor-pointer transition-all", + "hover:bg-accent/30", + isActive && "eve-glow-active bg-primary/10 border border-primary/50" + )} + > + + +
+
+ + {name} + + {showGroupBadge && onGroupClick && ( + + )} +
+ + {/* Percentage bar */} +
+
+
+
+ +
+
+ {item.count.toLocaleString()} +
+
+ {percentage.toFixed(1)}% +
+
+
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/src/hooks/useFilters.ts b/src/hooks/useFilters.ts new file mode 100644 index 0000000..df67198 --- /dev/null +++ b/src/hooks/useFilters.ts @@ -0,0 +1,121 @@ +import { useSearchParams } from 'react-router-dom'; +import { useCallback, useMemo } from 'react'; +import { FilterState, StatCategory } from '@/types/api'; + +function parseNumberArray(value: string | null): number[] { + if (!value) return []; + return value.split(',').map(Number).filter(n => !isNaN(n)); +} + +function serializeNumberArray(arr: number[]): string | undefined { + return arr.length > 0 ? arr.join(',') : undefined; +} + +export function useFilters() { + const [searchParams, setSearchParams] = useSearchParams(); + + const filters: FilterState = useMemo(() => ({ + ship: searchParams.get('ship') ? Number(searchParams.get('ship')) : undefined, + systems: parseNumberArray(searchParams.get('systems')), + modules: parseNumberArray(searchParams.get('modules')), + groups: parseNumberArray(searchParams.get('groups')), + }), [searchParams]); + + const setFilters = useCallback((newFilters: FilterState) => { + const params = new URLSearchParams(); + + if (newFilters.ship) { + params.set('ship', String(newFilters.ship)); + } + if (newFilters.systems.length > 0) { + params.set('systems', serializeNumberArray(newFilters.systems)!); + } + if (newFilters.modules.length > 0) { + params.set('modules', serializeNumberArray(newFilters.modules)!); + } + if (newFilters.groups.length > 0) { + params.set('groups', serializeNumberArray(newFilters.groups)!); + } + + setSearchParams(params); + }, [setSearchParams]); + + const toggleShip = useCallback((shipId: number) => { + setFilters({ + ...filters, + ship: filters.ship === shipId ? undefined : shipId, + }); + }, [filters, setFilters]); + + const toggleSystem = useCallback((systemId: number) => { + const systems = filters.systems.includes(systemId) + ? filters.systems.filter(id => id !== systemId) + : [...filters.systems, systemId]; + setFilters({ ...filters, systems }); + }, [filters, setFilters]); + + const toggleModule = useCallback((moduleId: number) => { + const modules = filters.modules.includes(moduleId) + ? filters.modules.filter(id => id !== moduleId) + : [...filters.modules, moduleId]; + setFilters({ ...filters, modules }); + }, [filters, setFilters]); + + const toggleGroup = useCallback((groupId: number) => { + const groups = filters.groups.includes(groupId) + ? filters.groups.filter(id => id !== groupId) + : [...filters.groups, groupId]; + setFilters({ ...filters, groups }); + }, [filters, setFilters]); + + const toggleItem = useCallback((itemId: number, category: StatCategory) => { + if (category === 'ships') { + toggleShip(itemId); + } else if (category === 'systemBreakdown') { + toggleSystem(itemId); + } else { + toggleModule(itemId); + } + }, [toggleShip, toggleSystem, toggleModule]); + + const isItemActive = useCallback((itemId: number, category: StatCategory): boolean => { + if (category === 'ships') { + return filters.ship === itemId; + } else if (category === 'systemBreakdown') { + return filters.systems.includes(itemId); + } else { + return filters.modules.includes(itemId); + } + }, [filters]); + + const clearAllFilters = useCallback(() => { + setSearchParams(new URLSearchParams()); + }, [setSearchParams]); + + const hasFilters = Boolean( + filters.ship || + filters.systems.length > 0 || + filters.modules.length > 0 || + filters.groups.length > 0 + ); + + const filterCount = + (filters.ship ? 1 : 0) + + filters.systems.length + + filters.modules.length + + filters.groups.length; + + return { + filters, + setFilters, + toggleShip, + toggleSystem, + toggleModule, + toggleGroup, + toggleItem, + isItemActive, + clearAllFilters, + hasFilters, + filterCount, + }; +} diff --git a/src/hooks/useGroupCache.ts b/src/hooks/useGroupCache.ts new file mode 100644 index 0000000..895c5a2 --- /dev/null +++ b/src/hooks/useGroupCache.ts @@ -0,0 +1,66 @@ +import { useCallback, useEffect, useState } from 'react'; +import { fetchItemGroup } from '@/lib/api'; + +const CACHE_KEY = 'eve-item-groups'; + +type GroupCache = Record; + +function loadCache(): GroupCache { + try { + const cached = localStorage.getItem(CACHE_KEY); + return cached ? JSON.parse(cached) : {}; + } catch { + return {}; + } +} + +function saveCache(cache: GroupCache) { + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); + } catch { + // Storage might be full + } +} + +export function useGroupCache() { + const [cache, setCache] = useState(loadCache); + const [pending, setPending] = useState>(new Set()); + + useEffect(() => { + saveCache(cache); + }, [cache]); + + const getGroup = useCallback((itemId: number): number | null => { + return cache[itemId] ?? null; + }, [cache]); + + const fetchGroup = useCallback(async (itemId: number): Promise => { + // Already cached + if (cache[itemId] !== undefined) { + return cache[itemId]; + } + + // Already fetching + if (pending.has(itemId)) { + return null; + } + + setPending(prev => new Set(prev).add(itemId)); + + try { + const result = await fetchItemGroup(itemId); + setCache(prev => ({ ...prev, [itemId]: result.groupId })); + return result.groupId; + } catch { + return null; + } finally { + setPending(prev => { + const next = new Set(prev); + next.delete(itemId); + return next; + }); + } + }, [cache, pending]); + + return { getGroup, fetchGroup }; +} diff --git a/src/hooks/useStatistics.ts b/src/hooks/useStatistics.ts new file mode 100644 index 0000000..36749ec --- /dev/null +++ b/src/hooks/useStatistics.ts @@ -0,0 +1,67 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchStatistics, fetchItemNames, searchItems, fetchItemGroup } from '@/lib/api'; +import { FilterState, FitStatistics, ItemCount } from '@/types/api'; +import { useMemo } from 'react'; + +export function useStatistics(filters: FilterState) { + return useQuery({ + queryKey: ['statistics', filters], + queryFn: () => fetchStatistics({ + ship: filters.ship, + systems: filters.systems.length > 0 ? filters.systems : undefined, + modules: filters.modules.length > 0 ? filters.modules : undefined, + groups: filters.groups.length > 0 ? filters.groups : undefined, + }), + staleTime: 30000, + }); +} + +export function useItemNames(ids: number[]) { + return useQuery({ + queryKey: ['itemNames', ids.sort().join(',')], + queryFn: () => fetchItemNames(ids), + enabled: ids.length > 0, + staleTime: Infinity, // Item names don't change + }); +} + +export function useSearch(query: string) { + return useQuery({ + queryKey: ['search', query], + queryFn: () => searchItems(query), + enabled: query.length >= 2, + staleTime: 60000, + }); +} + +export function useItemGroup(itemId: number | null) { + return useQuery({ + queryKey: ['itemGroup', itemId], + queryFn: () => fetchItemGroup(itemId!), + enabled: itemId !== null, + staleTime: Infinity, // Groups don't change + }); +} + +// Hook to extract all unique item IDs from statistics for batch name lookup +export function useStatisticsItemIds(stats: FitStatistics | undefined): number[] { + return useMemo(() => { + if (!stats) return []; + + const ids = new Set(); + + const addItems = (items: ItemCount[]) => { + items.forEach(item => ids.add(item.itemId)); + }; + + addItems(stats.ships); + addItems(stats.systemBreakdown); + addItems(stats.highSlotModules); + addItems(stats.midSlotModules); + addItems(stats.lowSlotModules); + addItems(stats.rigs); + addItems(stats.drones); + + return Array.from(ids); + }, [stats]); +} diff --git a/src/index.css b/src/index.css index 4844bbd..b299723 100644 --- a/src/index.css +++ b/src/index.css @@ -2,95 +2,96 @@ @tailwind components; @tailwind utilities; -/* Definition of the design system. All colors, gradients, fonts, etc should be defined here. -All colors MUST be HSL. -*/ +/* EVE Online Dark Theme - Deep space blues with cyan accents */ @layer base { :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; + --background: 216 28% 7%; + --foreground: 210 40% 92%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; + --card: 216 28% 9%; + --card-foreground: 210 40% 92%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; + --popover: 216 28% 11%; + --popover-foreground: 210 40% 92%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; + --primary: 187 80% 45%; + --primary-foreground: 216 28% 7%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; + --secondary: 216 25% 15%; + --secondary-foreground: 210 40% 92%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; + --muted: 216 25% 13%; + --muted-foreground: 215 20% 55%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; + --accent: 187 60% 35%; + --accent-foreground: 210 40% 98%; - --destructive: 0 84.2% 60.2%; + --destructive: 0 70% 50%; --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; + --border: 216 25% 18%; + --input: 216 25% 15%; + --ring: 187 80% 45%; - --radius: 0.5rem; + --radius: 0.375rem; - --sidebar-background: 0 0% 98%; + /* EVE-specific tokens */ + --eve-space: 216 28% 5%; + --eve-panel: 216 28% 10%; + --eve-glow: 187 80% 45%; + --eve-highlight: 187 100% 60%; + --eve-warning: 45 90% 55%; + --eve-success: 140 60% 45%; + --eve-active: 187 80% 45%; - --sidebar-foreground: 240 5.3% 26.1%; - - --sidebar-primary: 240 5.9% 10%; - - --sidebar-primary-foreground: 0 0% 98%; - - --sidebar-accent: 240 4.8% 95.9%; - - --sidebar-accent-foreground: 240 5.9% 10%; - - --sidebar-border: 220 13% 91%; - - --sidebar-ring: 217.2 91.2% 59.8%; + --sidebar-background: 216 28% 8%; + --sidebar-foreground: 210 40% 92%; + --sidebar-primary: 187 80% 45%; + --sidebar-primary-foreground: 216 28% 7%; + --sidebar-accent: 216 25% 15%; + --sidebar-accent-foreground: 210 40% 92%; + --sidebar-border: 216 25% 18%; + --sidebar-ring: 187 80% 45%; } .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; + --background: 216 28% 7%; + --foreground: 210 40% 92%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; + --card: 216 28% 9%; + --card-foreground: 210 40% 92%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; + --popover: 216 28% 11%; + --popover-foreground: 210 40% 92%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; + --primary: 187 80% 45%; + --primary-foreground: 216 28% 7%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; + --secondary: 216 25% 15%; + --secondary-foreground: 210 40% 92%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; + --muted: 216 25% 13%; + --muted-foreground: 215 20% 55%; - --accent: 217.2 32.6% 17.5%; + --accent: 187 60% 35%; --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; + --destructive: 0 70% 50%; --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; - --sidebar-background: 240 5.9% 10%; - --sidebar-foreground: 240 4.8% 95.9%; - --sidebar-primary: 224.3 76.3% 48%; - --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 240 3.7% 15.9%; - --sidebar-accent-foreground: 240 4.8% 95.9%; - --sidebar-border: 240 3.7% 15.9%; - --sidebar-ring: 217.2 91.2% 59.8%; + --border: 216 25% 18%; + --input: 216 25% 15%; + --ring: 187 80% 45%; + + --sidebar-background: 216 28% 8%; + --sidebar-foreground: 210 40% 92%; + --sidebar-primary: 187 80% 45%; + --sidebar-primary-foreground: 216 28% 7%; + --sidebar-accent: 216 25% 15%; + --sidebar-accent-foreground: 210 40% 92%; + --sidebar-border: 216 25% 18%; + --sidebar-ring: 187 80% 45%; } } @@ -101,5 +102,32 @@ All colors MUST be HSL. body { @apply bg-background text-foreground; + font-family: 'Segoe UI', system-ui, sans-serif; + } +} + +@layer utilities { + .eve-glow { + box-shadow: 0 0 20px hsl(var(--eve-glow) / 0.3), + inset 0 1px 0 hsl(var(--eve-highlight) / 0.1); + } + + .eve-glow-active { + box-shadow: 0 0 25px hsl(var(--eve-glow) / 0.5), + inset 0 0 15px hsl(var(--eve-glow) / 0.2); + } + + .eve-border-glow { + border-color: hsl(var(--eve-glow) / 0.5); + box-shadow: 0 0 10px hsl(var(--eve-glow) / 0.2); + } + + .eve-panel { + background: linear-gradient( + 180deg, + hsl(var(--eve-panel)) 0%, + hsl(var(--eve-space)) 100% + ); + border: 1px solid hsl(var(--border)); } } diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..99d454d --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,56 @@ +import { FitStatistics, SearchResult, ItemNames, GroupInfo, StatisticsRequest } from '@/types/api'; + +// TODO: Replace with your actual backend URL +const API_BASE = 'http://localhost:8080/api'; + +export async function fetchStatistics(request: StatisticsRequest): Promise { + const response = await fetch(`${API_BASE}/statistics`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + throw new Error('Failed to fetch statistics'); + } + + return response.json(); +} + +export async function searchItems(query: string): Promise { + if (!query.trim()) return []; + + const response = await fetch(`${API_BASE}/search?q=${encodeURIComponent(query)}`); + + if (!response.ok) { + throw new Error('Failed to search items'); + } + + return response.json(); +} + +export async function fetchItemNames(ids: number[]): Promise { + if (ids.length === 0) return {}; + + const response = await fetch(`${API_BASE}/items/names?ids=${ids.join(',')}`); + + if (!response.ok) { + throw new Error('Failed to fetch item names'); + } + + return response.json(); +} + +export async function fetchItemGroup(itemId: number): Promise { + const response = await fetch(`${API_BASE}/items/${itemId}/group`); + + if (!response.ok) { + throw new Error('Failed to fetch item group'); + } + + return response.json(); +} + +export function getEveImageUrl(typeId: number, size: number = 32): string { + return `https://images.evetech.net/types/${typeId}/icon?size=${size}`; +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 7130b54..31e31d0 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -1,14 +1,7 @@ -// Update this page (the content is just a fallback if you fail to update the page) +import { Dashboard } from '@/components/Dashboard'; const Index = () => { - return ( -
-
-

Welcome to Your Blank App

-

Start building your amazing project here!

-
-
- ); + return ; }; export default Index; diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..7c72caf --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,52 @@ +export interface ItemCount { + itemId: number; + count: number; +} + +export interface FitStatistics { + totalKillmails: number; + ships: ItemCount[]; + systemBreakdown: ItemCount[]; + highSlotModules: ItemCount[]; + midSlotModules: ItemCount[]; + lowSlotModules: ItemCount[]; + rigs: ItemCount[]; + drones: ItemCount[]; +} + +export interface StatisticsRequest { + ship?: number; + systems?: number[]; + modules?: number[]; + groups?: number[]; +} + +export interface SearchResult { + id: number; + name: string; + type: 'ship' | 'system' | 'module' | 'group'; +} + +export interface ItemNames { + [id: string]: string; +} + +export interface GroupInfo { + groupId: number; +} + +export interface FilterState { + ship?: number; + systems: number[]; + modules: number[]; + groups: number[]; +} + +export type StatCategory = + | 'ships' + | 'systemBreakdown' + | 'highSlotModules' + | 'midSlotModules' + | 'lowSlotModules' + | 'rigs' + | 'drones'; diff --git a/tailwind.config.ts b/tailwind.config.ts index a1edb69..e50d5af 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -57,6 +57,15 @@ export default { border: "hsl(var(--sidebar-border))", ring: "hsl(var(--sidebar-ring))", }, + eve: { + space: "hsl(var(--eve-space))", + panel: "hsl(var(--eve-panel))", + glow: "hsl(var(--eve-glow))", + highlight: "hsl(var(--eve-highlight))", + warning: "hsl(var(--eve-warning))", + success: "hsl(var(--eve-success))", + active: "hsl(var(--eve-active))", + }, }, borderRadius: { lg: "var(--radius)", @@ -65,25 +74,22 @@ export default { }, keyframes: { "accordion-down": { - from: { - height: "0", - }, - to: { - height: "var(--radix-accordion-content-height)", - }, + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, }, "accordion-up": { - from: { - height: "var(--radix-accordion-content-height)", - }, - to: { - height: "0", - }, + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + "glow-pulse": { + "0%, 100%": { opacity: "0.5" }, + "50%": { opacity: "1" }, }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", + "glow-pulse": "glow-pulse 2s ease-in-out infinite", }, }, },