Build Eve analytics UI skeleton

Implement initial EVE-themed analytics frontend:
- Added design system and dark theme CSS variables
- Created core TypeScript types for API shapes
- Implemented API hooks (fetch statistics, search, item names, groups)
- Built icon component for Eve images
- Implemented SearchBar, FilterBar, Dashboard, StatisticsPanel components
- Added item group caching hook
- Wired up index page to render the dashboard

Prepares for interactive drill-down analytics with URL-based filters and collapsible panels.

X-Lovable-Edit-ID: edt-b4a6a490-243d-43ae-9d94-2f8021a68ad3
This commit is contained in:
gpt-engineer-app[bot]
2026-01-05 20:50:50 +00:00
13 changed files with 1041 additions and 82 deletions

View File

@@ -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 (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur">
<div className="container mx-auto px-4 py-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<h1 className="text-xl font-bold text-primary">
zkill Analytics
</h1>
<SearchBar onSelect={handleSearchSelect} />
</div>
</div>
</header>
<main className="container mx-auto px-4 py-6 space-y-6">
{/* Filter Bar */}
<FilterBar
filters={filters}
itemNames={itemNames}
totalKillmails={stats?.totalKillmails ?? 0}
onRemoveShip={() => toggleShip(filters.ship!)}
onRemoveSystem={toggleSystem}
onRemoveModule={toggleModule}
onRemoveGroup={toggleGroup}
onClearAll={clearAllFilters}
/>
{/* Loading State */}
{isLoading && (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)}
{/* Error State */}
{error && (
<div className="text-center py-20 text-destructive">
Failed to load statistics. Please try again.
</div>
)}
{/* Statistics Panels */}
{stats && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{panels.map(({ title, category, showGroupBadge }) => (
<StatisticsPanel
key={category}
title={title}
items={stats[category]}
itemNames={itemNames}
category={category}
isItemActive={(id) => isItemActive(id, category)}
onItemClick={(id) => handleItemClick(id, category)}
onGroupClick={showGroupBadge ? handleGroupClick : undefined}
showGroupBadge={showGroupBadge}
/>
))}
</div>
)}
</main>
</div>
);
}

View File

@@ -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 (
<div
className={cn(
"bg-secondary rounded flex items-center justify-center text-muted-foreground text-xs",
className
)}
style={{ width: size, height: size }}
>
?
</div>
);
}
return (
<img
src={getEveImageUrl(typeId, size >= 64 ? 64 : 32)}
alt=""
width={size}
height={size}
className={cn("rounded", className)}
onError={() => setError(true)}
loading="lazy"
/>
);
}

View File

@@ -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 (
<div className="flex items-center gap-2 px-4 py-3 bg-secondary/50 rounded-md border border-border">
<span className="text-primary font-semibold">
{totalKillmails.toLocaleString()}
</span>
<span className="text-muted-foreground">killmails total</span>
</div>
);
}
return (
<div className="flex flex-wrap items-center gap-2 px-4 py-3 bg-secondary/50 rounded-md border border-border eve-border-glow">
<span className="text-primary font-semibold">
{totalKillmails.toLocaleString()}
</span>
<span className="text-muted-foreground">killmails</span>
<span className="text-muted-foreground mx-2">|</span>
{filters.ship && (
<FilterChip
id={filters.ship}
name={itemNames[filters.ship] || `#${filters.ship}`}
label="Ship"
onRemove={onRemoveShip}
/>
)}
{filters.systems.map(id => (
<FilterChip
key={id}
id={id}
name={itemNames[id] || `#${id}`}
label="System"
onRemove={() => onRemoveSystem(id)}
/>
))}
{filters.modules.map(id => (
<FilterChip
key={id}
id={id}
name={itemNames[id] || `#${id}`}
label="Module"
onRemove={() => onRemoveModule(id)}
/>
))}
{filters.groups.map(id => (
<FilterChip
key={id}
id={id}
name={itemNames[id] || `Group #${id}`}
label="Group"
onRemove={() => onRemoveGroup(id)}
showIcon={false}
/>
))}
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="ml-auto text-muted-foreground hover:text-destructive"
>
Clear all
</Button>
</div>
);
}
interface FilterChipProps {
id: number;
name: string;
label: string;
onRemove: () => void;
showIcon?: boolean;
}
function FilterChip({ id, name, label, onRemove, showIcon = true }: FilterChipProps) {
return (
<div className="flex items-center gap-1.5 pl-1 pr-2 py-1 bg-primary/20 border border-primary/40 rounded text-sm">
{showIcon && <EveIcon typeId={id} size={20} />}
<span className="text-xs text-primary/70">{label}:</span>
<span className="font-medium">{name}</span>
<button
onClick={onRemove}
className="ml-1 text-muted-foreground hover:text-destructive transition-colors"
>
<X className="h-3 w-3" />
</button>
</div>
);
}

View File

@@ -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<HTMLDivElement>(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<SearchResult['type'], string> = {
ship: 'Ship',
system: 'System',
module: 'Module',
group: 'Group',
};
return (
<div ref={containerRef} className="relative w-full max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={query}
onChange={(e) => {
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 && (
<button
onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{isOpen && query.length >= 2 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-80 overflow-auto eve-panel">
{isLoading && (
<div className="p-3 text-center text-muted-foreground text-sm">
Searching...
</div>
)}
{!isLoading && results?.length === 0 && (
<div className="p-3 text-center text-muted-foreground text-sm">
No results found
</div>
)}
{!isLoading && results?.map((result) => (
<button
key={`${result.type}-${result.id}`}
onClick={() => handleSelect(result)}
className={cn(
"w-full flex items-center gap-3 p-2 hover:bg-accent/50 text-left transition-colors",
"first:rounded-t-md last:rounded-b-md"
)}
>
<EveIcon typeId={result.id} size={24} />
<span className="flex-1 truncate">{result.name}</span>
<span className="text-xs text-muted-foreground px-2 py-0.5 bg-secondary rounded">
{typeLabels[result.type]}
</span>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -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 (
<div className="eve-panel rounded-md overflow-hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center gap-2 p-3 hover:bg-accent/30 transition-colors"
>
{isOpen ? (
<ChevronDown className="h-4 w-4 text-primary" />
) : (
<ChevronRight className="h-4 w-4 text-primary" />
)}
<span className="font-semibold">{title}</span>
<span className="text-muted-foreground text-sm ml-auto">
{items.length} items
</span>
</button>
{isOpen && (
<div className="p-3 pt-0 space-y-4">
{/* Bar Chart */}
{topItems.length > 0 && (
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
width={120}
tick={{ fill: 'hsl(var(--foreground))', fontSize: 11 }}
tickLine={false}
axisLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '0.375rem',
}}
labelStyle={{ color: 'hsl(var(--foreground))' }}
formatter={(value: number) => [value.toLocaleString(), 'Count']}
/>
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
{chartData.map((entry) => (
<Cell
key={entry.id}
fill={isItemActive(entry.id) ? 'hsl(var(--primary))' : 'hsl(var(--accent))'}
className="cursor-pointer"
onClick={() => onItemClick(entry.id)}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* Table */}
<div className="space-y-1 max-h-80 overflow-auto">
{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 (
<div
key={item.itemId}
onClick={() => 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"
)}
>
<EveIcon typeId={item.itemId} size={28} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={cn(
"truncate",
isActive && "text-primary font-medium"
)}>
{name}
</span>
{showGroupBadge && onGroupClick && (
<button
onClick={(e) => {
e.stopPropagation();
onGroupClick(item.itemId);
}}
className="px-1.5 py-0.5 text-[10px] bg-secondary hover:bg-accent rounded transition-colors"
title="Filter by group"
>
GRP
</button>
)}
</div>
{/* Percentage bar */}
<div className="h-1 mt-1 bg-secondary rounded overflow-hidden">
<div
className={cn(
"h-full rounded transition-all",
isActive ? "bg-primary" : "bg-accent"
)}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
<div className="text-right">
<div className={cn(
"font-mono text-sm",
isActive && "text-primary"
)}>
{item.count.toLocaleString()}
</div>
<div className="text-xs text-muted-foreground">
{percentage.toFixed(1)}%
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

121
src/hooks/useFilters.ts Normal file
View File

@@ -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,
};
}

View File

@@ -0,0 +1,66 @@
import { useCallback, useEffect, useState } from 'react';
import { fetchItemGroup } from '@/lib/api';
const CACHE_KEY = 'eve-item-groups';
type GroupCache = Record<number, number>;
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<GroupCache>(loadCache);
const [pending, setPending] = useState<Set<number>>(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<number | null> => {
// 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 };
}

View File

@@ -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<number>();
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]);
}

View File

@@ -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));
}
}

56
src/lib/api.ts Normal file
View File

@@ -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<FitStatistics> {
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<SearchResult[]> {
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<ItemNames> {
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<GroupInfo> {
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}`;
}

View File

@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">Welcome to Your Blank App</h1>
<p className="text-xl text-muted-foreground">Start building your amazing project here!</p>
</div>
</div>
);
return <Dashboard />;
};
export default Index;

52
src/types/api.ts Normal file
View File

@@ -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';

View File

@@ -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",
},
},
},