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 */}
+
+
+
+ {/* 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",
},
},
},