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:
122
src/components/Dashboard.tsx
Normal file
122
src/components/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/components/EveIcon.tsx
Normal file
39
src/components/EveIcon.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
128
src/components/FilterBar.tsx
Normal file
128
src/components/FilterBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
src/components/SearchBar.tsx
Normal file
100
src/components/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
181
src/components/StatisticsPanel.tsx
Normal file
181
src/components/StatisticsPanel.tsx
Normal 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
121
src/hooks/useFilters.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
66
src/hooks/useGroupCache.ts
Normal file
66
src/hooks/useGroupCache.ts
Normal 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 };
|
||||
}
|
||||
67
src/hooks/useStatistics.ts
Normal file
67
src/hooks/useStatistics.ts
Normal 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]);
|
||||
}
|
||||
150
src/index.css
150
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));
|
||||
}
|
||||
}
|
||||
|
||||
56
src/lib/api.ts
Normal file
56
src/lib/api.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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
52
src/types/api.ts
Normal 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';
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user