initial
This commit is contained in:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<title>marathon.todo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
BIN
frontend/public/fonts/Ki-Bold.otf
Normal file
BIN
frontend/public/fonts/Ki-Bold.otf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/Ki-Regular.otf
Normal file
BIN
frontend/public/fonts/Ki-Regular.otf
Normal file
Binary file not shown.
814
frontend/src/App.tsx
Normal file
814
frontend/src/App.tsx
Normal file
@@ -0,0 +1,814 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { buildImageSearchCandidates, getAllItems, getPopularResults, searchResultsByName, trackResultAdded } from './marathonApi';
|
||||
import type { SearchItem, SearchResult, TodoItem, UpgradeLevel, UpgradeSearchResult } from './types';
|
||||
|
||||
const STORAGE_KEY = 'marathon.todo.items';
|
||||
const SHARE_PARAM = 'l';
|
||||
const SHARE_VERSION = '1';
|
||||
const RARITY_GLOW_COLORS: Record<string, string> = {
|
||||
standard: '#CACAD6',
|
||||
enhanced: '#00FF7D',
|
||||
deluxe: '#21B4FA',
|
||||
superior: '#E912F6',
|
||||
prestige: '#FFF40C',
|
||||
};
|
||||
|
||||
function getRarityGlowColor(rarity?: string): string | null {
|
||||
if (!rarity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RARITY_GLOW_COLORS[rarity.trim().toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
function isUpgradeResult(result: SearchResult): result is UpgradeSearchResult {
|
||||
return 'isUpgrade' in result;
|
||||
}
|
||||
|
||||
function ItemIcon({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
size = 32,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const [candidateIndex, setCandidateIndex] = useState(0);
|
||||
const candidates = useMemo(() => buildImageSearchCandidates(src), [src]);
|
||||
const currentCandidate = candidates[candidateIndex] ?? src;
|
||||
|
||||
useEffect(() => {
|
||||
setCandidateIndex(0);
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={currentCandidate}
|
||||
alt={alt}
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
onError={() => {
|
||||
if (candidates.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCandidateIndex((current) => Math.min(current + 1, candidates.length - 1));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function loadTodoItems(): TodoItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed
|
||||
.filter((row): row is TodoItem => {
|
||||
return (
|
||||
typeof row === 'object' &&
|
||||
row !== null &&
|
||||
typeof row.id === 'string' &&
|
||||
typeof row.slug === 'string' &&
|
||||
typeof row.name === 'string' &&
|
||||
typeof row.iconUrl === 'string' &&
|
||||
(row.rarity === undefined || typeof row.rarity === 'string') &&
|
||||
typeof row.quantity === 'number' &&
|
||||
typeof row.completed === 'boolean'
|
||||
);
|
||||
})
|
||||
.map((item) => ({
|
||||
...item,
|
||||
rarity: typeof item.rarity === 'string' ? item.rarity : undefined,
|
||||
quantity: Math.max(1, Math.floor(item.quantity)),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toBase64Url(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = '';
|
||||
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function fromBase64Url(value: string): string | null {
|
||||
try {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
||||
const binary = atob(padded);
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeListForUrl(items: TodoItem[]): string {
|
||||
if (items.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const payload = items
|
||||
.map((item) => {
|
||||
const quantity = Math.max(1, Math.floor(item.quantity));
|
||||
return `${encodeURIComponent(item.slug)},${quantity},${item.completed ? 1 : 0}`;
|
||||
})
|
||||
.join(';');
|
||||
|
||||
return toBase64Url(`${SHARE_VERSION}|${payload}`);
|
||||
}
|
||||
|
||||
function decodeListFromUrl(encoded: string, allItems: SearchItem[]): TodoItem[] | null {
|
||||
const decoded = fromBase64Url(encoded);
|
||||
if (!decoded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [version, rawItems = ''] = decoded.split('|', 2);
|
||||
if (version !== SHARE_VERSION) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!rawItems) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const bySlug = new Map(allItems.map((item) => [item.slug, item]));
|
||||
|
||||
const mapped = rawItems
|
||||
.split(';')
|
||||
.map((entry, index): TodoItem | null => {
|
||||
const [rawSlug, rawQuantity, rawCompleted] = entry.split(',', 3);
|
||||
if (!rawSlug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slug = decodeURIComponent(rawSlug);
|
||||
const match = bySlug.get(slug);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const quantity = Math.max(1, Number(rawQuantity) || 1);
|
||||
const completed = rawCompleted === '1';
|
||||
|
||||
return {
|
||||
id: `${slug}-${index}`,
|
||||
slug: match.slug,
|
||||
name: match.name,
|
||||
iconUrl: match.iconUrl,
|
||||
rarity: match.rarity,
|
||||
quantity,
|
||||
completed,
|
||||
};
|
||||
})
|
||||
.filter((item): item is TodoItem => item !== null);
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [minimalMode, setMinimalMode] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [todoItems, setTodoItems] = useState<TodoItem[]>(() => loadTodoItems());
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [popularResults, setPopularResults] = useState<SearchResult[]>([]);
|
||||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
const [popularLoading, setPopularLoading] = useState(false);
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
const [highlightColorsBySlug, setHighlightColorsBySlug] = useState<Record<string, string>>({});
|
||||
const highlightTimeoutsRef = useRef<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const initializeCatalogAndSharedList = async () => {
|
||||
const sharedState = new URL(window.location.href).searchParams.get(SHARE_PARAM);
|
||||
|
||||
try {
|
||||
const allItems = await getAllItems();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rarityBySlug = new Map(allItems.map((item) => [item.slug, item.rarity]));
|
||||
setTodoItems((prevItems) =>
|
||||
prevItems.map((item) => ({
|
||||
...item,
|
||||
rarity: item.rarity ?? rarityBySlug.get(item.slug),
|
||||
})),
|
||||
);
|
||||
|
||||
if (!sharedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decodedItems = decodeListFromUrl(sharedState, allItems);
|
||||
if (decodedItems) {
|
||||
setTodoItems(decodedItems);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled && sharedState) {
|
||||
setSearchError('Failed to load shared list.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void initializeCatalogAndSharedList();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(todoItems));
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
const encoded = encodeListForUrl(todoItems);
|
||||
if (encoded) {
|
||||
url.searchParams.set(SHARE_PARAM, encoded);
|
||||
} else {
|
||||
url.searchParams.delete(SHARE_PARAM);
|
||||
}
|
||||
|
||||
const nextUrl = `${url.pathname}${url.search}${url.hash}`;
|
||||
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
|
||||
if (nextUrl !== currentUrl) {
|
||||
window.history.replaceState(null, '', nextUrl);
|
||||
}
|
||||
}, [todoItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => setActionMessage(null), 2200);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [actionMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const timeoutId of Object.values(highlightTimeoutsRef.current)) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmedQuery = query.trim();
|
||||
if (!trimmedQuery) {
|
||||
setResults([]);
|
||||
setSearchError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(async () => {
|
||||
setSearchLoading(true);
|
||||
setSearchError(null);
|
||||
|
||||
try {
|
||||
const matchedResults = await searchResultsByName(trimmedQuery, 5);
|
||||
setResults(matchedResults);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
setSearchError(message);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
const completedCount = useMemo(() => todoItems.filter((item) => item.completed).length, [todoItems]);
|
||||
|
||||
function triggerTodoHighlights(slugs: string[], color: string): void {
|
||||
const uniqueSlugs = Array.from(new Set(slugs));
|
||||
if (uniqueSlugs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHighlightColorsBySlug((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const slug of uniqueSlugs) {
|
||||
delete next[slug];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
setHighlightColorsBySlug((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const slug of uniqueSlugs) {
|
||||
next[slug] = color;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
for (const slug of uniqueSlugs) {
|
||||
const existingTimeoutId = highlightTimeoutsRef.current[slug];
|
||||
if (existingTimeoutId) {
|
||||
window.clearTimeout(existingTimeoutId);
|
||||
}
|
||||
|
||||
highlightTimeoutsRef.current[slug] = window.setTimeout(() => {
|
||||
setHighlightColorsBySlug((prev) => {
|
||||
if (!prev[slug]) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = { ...prev };
|
||||
delete next[slug];
|
||||
return next;
|
||||
});
|
||||
delete highlightTimeoutsRef.current[slug];
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
function addTodoItem(item: SearchItem): void {
|
||||
setTodoItems((prevItems) => {
|
||||
const existing = prevItems.find((entry) => entry.slug === item.slug);
|
||||
if (existing) {
|
||||
return prevItems.map((entry) =>
|
||||
entry.slug === item.slug ? { ...entry, quantity: entry.quantity + 1 } : entry,
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: `${item.slug}-${Date.now()}`,
|
||||
slug: item.slug,
|
||||
name: item.name,
|
||||
iconUrl: item.iconUrl,
|
||||
rarity: item.rarity,
|
||||
quantity: 1,
|
||||
completed: false,
|
||||
},
|
||||
...prevItems,
|
||||
];
|
||||
});
|
||||
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
}
|
||||
|
||||
function addUpgradeLevelSalvage(upgrade: UpgradeSearchResult, level: UpgradeLevel): void {
|
||||
if (level.salvage.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTodoItems((prevItems) => {
|
||||
const nextItems = [...prevItems];
|
||||
|
||||
for (const salvageEntry of level.salvage) {
|
||||
const { slug } = salvageEntry;
|
||||
const amount = Math.max(1, Math.floor(salvageEntry.amount));
|
||||
const existingIndex = nextItems.findIndex((entry) => entry.slug === slug);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
nextItems[existingIndex] = {
|
||||
...nextItems[existingIndex],
|
||||
quantity: nextItems[existingIndex].quantity + amount,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
nextItems.unshift({
|
||||
id: `${slug}-${Date.now()}-${amount}`,
|
||||
slug,
|
||||
name: salvageEntry.name,
|
||||
iconUrl: salvageEntry.iconUrl,
|
||||
rarity: salvageEntry.rarity,
|
||||
quantity: amount,
|
||||
completed: false,
|
||||
});
|
||||
}
|
||||
|
||||
return nextItems;
|
||||
});
|
||||
|
||||
triggerTodoHighlights(
|
||||
level.salvage.map((entry) => entry.slug),
|
||||
upgrade.factionColor,
|
||||
);
|
||||
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
}
|
||||
|
||||
function addSearchResult(result: SearchResult): void {
|
||||
if (isUpgradeResult(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void trackResultAdded(result);
|
||||
addTodoItem(result);
|
||||
}
|
||||
|
||||
async function handleSearchFocus(): Promise<void> {
|
||||
setSearchFocused(true);
|
||||
|
||||
if (query.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPopularLoading(true);
|
||||
try {
|
||||
const popular = await getPopularResults(5);
|
||||
setPopularResults(popular);
|
||||
} catch {
|
||||
setPopularResults([]);
|
||||
} finally {
|
||||
setPopularLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateQuantity(id: string, quantity: number): void {
|
||||
setTodoItems((prevItems) =>
|
||||
prevItems.map((item) => (item.id === id ? { ...item, quantity: Math.max(1, quantity) } : item)),
|
||||
);
|
||||
}
|
||||
|
||||
function incrementQuantity(id: string): void {
|
||||
setTodoItems((prevItems) =>
|
||||
prevItems.map((item) => (item.id === id ? { ...item, quantity: item.quantity + 1 } : item)),
|
||||
);
|
||||
}
|
||||
|
||||
function decrementQuantity(id: string): void {
|
||||
setTodoItems((prevItems) =>
|
||||
prevItems.map((item) =>
|
||||
item.id === id ? { ...item, quantity: Math.max(1, item.quantity - 1) } : item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function toggleCompleted(id: string): void {
|
||||
setTodoItems((prevItems) =>
|
||||
prevItems.map((item) => (item.id === id ? { ...item, completed: !item.completed } : item)),
|
||||
);
|
||||
}
|
||||
|
||||
function deleteItem(id: string): void {
|
||||
setTodoItems((prevItems) => prevItems.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
async function shareList(): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
setActionMessage('Share link copied.');
|
||||
} catch {
|
||||
setActionMessage('Failed to copy share link.');
|
||||
}
|
||||
}
|
||||
|
||||
function resetList(): void {
|
||||
setTodoItems([]);
|
||||
setActionMessage('List reset.');
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={minimalMode ? 'app minimal-mode' : 'app'}>
|
||||
<header>
|
||||
<div className="header-top">
|
||||
<div>
|
||||
<h1>marathon.todo</h1>
|
||||
{!minimalMode ? <p>Plan what to loot (or do) in your next Marathon raid.</p> : null}
|
||||
</div>
|
||||
{!minimalMode ? (
|
||||
<button type="button" className="header-btn" onClick={() => setMinimalMode(true)}>
|
||||
Minimal mode
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="header-btn" onClick={() => setMinimalMode(false)}>
|
||||
Exit minimal mode
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!minimalMode ? (
|
||||
<section className="card">
|
||||
<label htmlFor="item-search">Search items</label>
|
||||
<div className="search-box">
|
||||
<input
|
||||
id="item-search"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onFocus={() => void handleSearchFocus()}
|
||||
onBlur={() => window.setTimeout(() => setSearchFocused(false), 120)}
|
||||
placeholder="Type an item name..."
|
||||
/>
|
||||
|
||||
{searchLoading ? <p className="status search-status">Searching...</p> : null}
|
||||
{searchError ? <p className="status error search-status">Search failed: {searchError}</p> : null}
|
||||
|
||||
{query.trim() && !searchLoading && !searchError ? (
|
||||
<ul className="results" aria-label="Search results">
|
||||
{results.length === 0 ? <li className="status">No matching items.</li> : null}
|
||||
{results.map((item) => (
|
||||
<li key={item.slug}>
|
||||
{isUpgradeResult(item) ? (
|
||||
<div className="result-row upgrade-row">
|
||||
<div className="result-main">
|
||||
<span
|
||||
className="result-icon-frame"
|
||||
style={
|
||||
({
|
||||
'--rarity-glow-color': item.factionColor || '#3a4f77',
|
||||
} as React.CSSProperties)
|
||||
}
|
||||
>
|
||||
<ItemIcon src={item.iconUrl} alt="" />
|
||||
</span>
|
||||
<span className="result-name">{item.name}</span>
|
||||
<span
|
||||
className="upgrade-tag"
|
||||
style={{
|
||||
color: item.factionColor || '#a9b8d6',
|
||||
}}
|
||||
>
|
||||
{item.factionName} upgrade
|
||||
</span>
|
||||
</div>
|
||||
<div className="upgrade-levels" aria-label={`Levels for ${item.name}`}>
|
||||
<span className="level-label">Level:</span>
|
||||
{item.levels.map((level) => (
|
||||
<button
|
||||
key={`${item.slug}-level-${level.level}`}
|
||||
type="button"
|
||||
className="level-button"
|
||||
disabled={level.salvage.length === 0}
|
||||
onClick={() => {
|
||||
void trackResultAdded(item);
|
||||
addUpgradeLevelSalvage(item, level);
|
||||
}}
|
||||
>
|
||||
{level.level}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => addSearchResult(item)}>
|
||||
<span
|
||||
className="result-icon-frame"
|
||||
style={
|
||||
getRarityGlowColor(item.rarity)
|
||||
? ({ '--rarity-glow-color': getRarityGlowColor(item.rarity) } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ItemIcon src={item.iconUrl} alt="" />
|
||||
</span>
|
||||
<span
|
||||
className="result-name"
|
||||
style={getRarityGlowColor(item.rarity) ? { color: getRarityGlowColor(item.rarity)! } : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{!query.trim() && searchFocused ? (
|
||||
<>
|
||||
<ul className="results" aria-label="Popular picks">
|
||||
<li className="status">Popular picks</li>
|
||||
{popularLoading ? <li className="status">Loading popular picks...</li> : null}
|
||||
{!popularLoading && popularResults.length === 0 ? <li className="status">No popular picks yet.</li> : null}
|
||||
{!popularLoading
|
||||
? popularResults.map((item) => (
|
||||
<li key={`popular-${item.slug}`}>
|
||||
{isUpgradeResult(item) ? (
|
||||
<div className="result-row upgrade-row">
|
||||
<div className="result-main">
|
||||
<span
|
||||
className="result-icon-frame"
|
||||
style={
|
||||
({
|
||||
'--rarity-glow-color': item.factionColor || '#3a4f77',
|
||||
} as React.CSSProperties)
|
||||
}
|
||||
>
|
||||
<ItemIcon src={item.iconUrl} alt="" />
|
||||
</span>
|
||||
<span className="result-name">{item.name}</span>
|
||||
<span
|
||||
className="upgrade-tag"
|
||||
style={{
|
||||
color: item.factionColor || '#a9b8d6',
|
||||
}}
|
||||
>
|
||||
{item.factionName} upgrade
|
||||
</span>
|
||||
</div>
|
||||
<div className="upgrade-levels" aria-label={`Levels for ${item.name}`}>
|
||||
<span className="level-label">Level:</span>
|
||||
{item.levels.map((level) => (
|
||||
<button
|
||||
key={`popular-${item.slug}-level-${level.level}`}
|
||||
type="button"
|
||||
className="level-button"
|
||||
disabled={level.salvage.length === 0}
|
||||
onClick={() => {
|
||||
void trackResultAdded(item);
|
||||
addUpgradeLevelSalvage(item, level);
|
||||
}}
|
||||
>
|
||||
{level.level}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => addSearchResult(item)}>
|
||||
<span
|
||||
className="result-icon-frame"
|
||||
style={
|
||||
getRarityGlowColor(item.rarity)
|
||||
? ({ '--rarity-glow-color': getRarityGlowColor(item.rarity) } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ItemIcon src={item.iconUrl} alt="" />
|
||||
</span>
|
||||
<span
|
||||
className="result-name"
|
||||
style={getRarityGlowColor(item.rarity) ? { color: getRarityGlowColor(item.rarity)! } : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
: null}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="card">
|
||||
<div className="list-header">
|
||||
<h2>To-do list</h2>
|
||||
{!minimalMode && todoItems.length > 0 ? (
|
||||
<div className="header-actions">
|
||||
<button type="button" className="header-btn" onClick={() => void shareList()}>
|
||||
Share
|
||||
</button>
|
||||
<button type="button" className="header-btn danger" onClick={resetList}>
|
||||
Reset list
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!minimalMode ? (
|
||||
<p className="status">
|
||||
Completed {completedCount} / {todoItems.length}
|
||||
</p>
|
||||
) : null}
|
||||
{!minimalMode && actionMessage ? <p className="status">{actionMessage}</p> : null}
|
||||
|
||||
{todoItems.length === 0 ? (
|
||||
<p className="status">{minimalMode ? 'No items yet.' : 'No items yet. Add one from search.'}</p>
|
||||
) : null}
|
||||
|
||||
<ul className={minimalMode ? 'todo-list minimal-list' : 'todo-list'} aria-label="To-do items">
|
||||
{todoItems.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={
|
||||
minimalMode
|
||||
? `todo-item minimal-item${item.completed ? ' done' : ''}`
|
||||
: item.completed
|
||||
? `todo-item done${highlightColorsBySlug[item.slug] ? ' highlighted' : ''}`
|
||||
: `todo-item${highlightColorsBySlug[item.slug] ? ' highlighted' : ''}`
|
||||
}
|
||||
style={
|
||||
!minimalMode && highlightColorsBySlug[item.slug]
|
||||
? ({ '--highlight-color': highlightColorsBySlug[item.slug] } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
onClick={() => toggleCompleted(item.id)}
|
||||
>
|
||||
<span
|
||||
className="todo-icon-frame"
|
||||
style={
|
||||
getRarityGlowColor(item.rarity)
|
||||
? ({ '--rarity-glow-color': getRarityGlowColor(item.rarity) } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ItemIcon src={item.iconUrl} alt="" size={37} />
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="item-name"
|
||||
style={getRarityGlowColor(item.rarity) ? { color: getRarityGlowColor(item.rarity)! } : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
|
||||
{minimalMode ? (
|
||||
<span className="minimal-qty">x{item.quantity}</span>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="quantity-field"
|
||||
aria-label={`Quantity controls for ${item.name}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button type="button" className="qty-arrow" onClick={() => decrementQuantity(item.id)}>
|
||||
-
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={item.quantity}
|
||||
onChange={(event) => updateQuantity(item.id, Number(event.target.value) || 1)}
|
||||
aria-label={`Quantity for ${item.name}`}
|
||||
/>
|
||||
<button type="button" className="qty-arrow" onClick={() => incrementQuantity(item.id)}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="delete"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
deleteItem(item.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{!minimalMode ? (
|
||||
<footer className="legal-footer" aria-label="Legal information">
|
||||
<section className="legal-section" aria-labelledby="privacy-heading">
|
||||
<h2 id="privacy-heading">Privacy Policy</h2>
|
||||
<p>
|
||||
This site stores your to-do list locally in your browser so your list can persist between visits and be
|
||||
shared by link. No accounts are required and no personal data is sold.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="legal-section" aria-labelledby="contact-heading">
|
||||
<h2 id="contact-heading">Contact</h2>
|
||||
<p>
|
||||
E-mail: <a href="mailto:alshuriga@gmail.com">alshuriga@gmail.com</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="legal-section" aria-labelledby="legal-heading">
|
||||
<h2 id="legal-heading">Info</h2>
|
||||
<p>Marathon™ is owned by Bungie, Inc. This website is unofficial and has no affiliation with or endorsement from Bungie.</p>
|
||||
</section>
|
||||
</footer>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
157
frontend/src/marathonApi.ts
Normal file
157
frontend/src/marathonApi.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { SearchItem, SearchResult, UpgradeSearchResult } from './types';
|
||||
|
||||
const IMAGE_EXTENSION_RE = /\.(png|jpe?g|gif|webp)(?:[?#].*)?$/i;
|
||||
const IMAGE_FALLBACK_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp'] as const;
|
||||
|
||||
interface CatalogResponse {
|
||||
updatedAt: string;
|
||||
items: SearchItem[];
|
||||
upgrades: UpgradeSearchResult[];
|
||||
}
|
||||
|
||||
interface PopularResponse {
|
||||
picks: SearchResult[];
|
||||
}
|
||||
|
||||
let catalogPromise: Promise<CatalogResponse> | null = null;
|
||||
|
||||
function normalizeCatalog(payload: CatalogResponse): CatalogResponse {
|
||||
const normalizedUpgrades = payload.upgrades.map((upgrade) => {
|
||||
if (Array.isArray(upgrade.levels)) {
|
||||
return upgrade;
|
||||
}
|
||||
|
||||
const legacyUpgrade = upgrade as UpgradeSearchResult & { salvage?: UpgradeSearchResult['levels'][number]['salvage'] };
|
||||
const salvage = Array.isArray(legacyUpgrade.salvage) ? legacyUpgrade.salvage : [];
|
||||
|
||||
return {
|
||||
...upgrade,
|
||||
levels: [{ level: 1, salvage }],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...payload,
|
||||
upgrades: normalizedUpgrades,
|
||||
};
|
||||
}
|
||||
|
||||
function scoreResult(name: string, query: string): number {
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
if (lowerName === query) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (lowerName.startsWith(query)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const index = lowerName.indexOf(query);
|
||||
if (index >= 0) {
|
||||
return 2 + index;
|
||||
}
|
||||
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
async function fetchCatalog(): Promise<CatalogResponse> {
|
||||
const response = await fetch('/api/catalog');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch catalog: ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as CatalogResponse;
|
||||
if (!Array.isArray(payload.items) || !Array.isArray(payload.upgrades)) {
|
||||
throw new Error('Invalid catalog payload');
|
||||
}
|
||||
|
||||
return normalizeCatalog(payload);
|
||||
}
|
||||
|
||||
async function getCatalog(): Promise<CatalogResponse> {
|
||||
if (!catalogPromise) {
|
||||
catalogPromise = fetchCatalog().catch((error: unknown) => {
|
||||
catalogPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return catalogPromise;
|
||||
}
|
||||
|
||||
export function buildImageSearchCandidates(iconUrl: string): string[] {
|
||||
if (!iconUrl) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (IMAGE_EXTENSION_RE.test(iconUrl)) {
|
||||
return [iconUrl];
|
||||
}
|
||||
|
||||
const candidates = [iconUrl, ...IMAGE_FALLBACK_EXTENSIONS.map((ext) => `${iconUrl}.${ext}`)];
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
|
||||
export async function getAllItems(): Promise<SearchItem[]> {
|
||||
const catalog = await getCatalog();
|
||||
return catalog.items;
|
||||
}
|
||||
|
||||
export async function searchResultsByName(query: string, limit = 5): Promise<SearchResult[]> {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const catalog = await getCatalog();
|
||||
|
||||
const candidates: SearchResult[] = [
|
||||
...catalog.items.filter((item) => item.name.toLowerCase().includes(normalizedQuery)),
|
||||
...catalog.upgrades.filter((upgrade) => upgrade.name.toLowerCase().includes(normalizedQuery)),
|
||||
];
|
||||
|
||||
return candidates
|
||||
.sort((a, b) => {
|
||||
const scoreDiff = scoreResult(a.name, normalizedQuery) - scoreResult(b.name, normalizedQuery);
|
||||
if (scoreDiff !== 0) {
|
||||
return scoreDiff;
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export async function getPopularResults(limit = 5): Promise<SearchResult[]> {
|
||||
const response = await fetch(`/api/popularity?limit=${Math.max(1, Math.floor(limit))}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch popular picks: ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as PopularResponse;
|
||||
if (!Array.isArray(payload.picks)) {
|
||||
throw new Error('Invalid popular picks payload');
|
||||
}
|
||||
|
||||
return payload.picks;
|
||||
}
|
||||
|
||||
export async function trackResultAdded(result: SearchResult): Promise<void> {
|
||||
const payload = {
|
||||
type: 'isUpgrade' in result ? 'upgrade' : 'item',
|
||||
slug: result.slug,
|
||||
};
|
||||
|
||||
try {
|
||||
await fetch('/api/popularity/track', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch {
|
||||
// Ignore analytics failures so add-to-list UX is never blocked.
|
||||
}
|
||||
}
|
||||
509
frontend/src/styles.css
Normal file
509
frontend/src/styles.css
Normal file
@@ -0,0 +1,509 @@
|
||||
@font-face {
|
||||
font-family: 'Ki-Regular';
|
||||
src: url('/fonts/Ki-Regular.otf') format('opentype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ki-Bold';
|
||||
src: url('/fonts/Ki-Bold.otf') format('opentype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--ui-dark: #1e2125;
|
||||
--ui-dark-elevated: #212529;
|
||||
--ui-accent: #caf61d;
|
||||
--color-text-main: #e8edf7;
|
||||
--color-text-input: #f2f6ff;
|
||||
--color-text-muted: #d6dbe8;
|
||||
--color-text-done: #8d9bbb;
|
||||
--color-border: #444444;
|
||||
--color-hover: #31373d;
|
||||
--color-error: #ff9aa2;
|
||||
--color-rarity-fallback: #6f6f6f;
|
||||
--shadow-card: 0 10px 30px rgba(0, 0, 0, 0.45);
|
||||
--shadow-dropdown: 0 14px 34px rgba(2, 6, 12, 0.65);
|
||||
--control-height: 34px;
|
||||
font-family: 'Ki-Regular', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
color: var(--color-text-main);
|
||||
background: var(--ui-dark);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--color-text-main);
|
||||
background: var(--ui-dark);
|
||||
}
|
||||
|
||||
body,
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: 'Ki-Regular', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: 'Ki-Bold', 'Ki-Regular', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
color: var(--ui-accent);
|
||||
}
|
||||
|
||||
label {
|
||||
color: var(--ui-accent);
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--ui-dark-elevated);
|
||||
color: var(--color-text-main);
|
||||
border-radius: 0;
|
||||
height: var(--control-height);
|
||||
min-height: var(--control-height);
|
||||
padding: 0 0.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.header-btn.danger {
|
||||
border-color: var(--color-border);
|
||||
background: var(--ui-dark-elevated);
|
||||
}
|
||||
|
||||
.header-btn.danger:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--ui-dark-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
input[type='search'],
|
||||
input[type='number'] {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
padding: 0.5rem;
|
||||
color: var(--color-text-input);
|
||||
background: var(--ui-dark);
|
||||
}
|
||||
|
||||
.results,
|
||||
.todo-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-status {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.results {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
background: var(--ui-dark);
|
||||
box-shadow: var(--shadow-dropdown);
|
||||
}
|
||||
|
||||
.results button {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--ui-dark-elevated);
|
||||
color: var(--color-text-main);
|
||||
padding: 0.5rem;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.result-row {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--ui-dark-elevated);
|
||||
color: var(--color-text-main);
|
||||
padding: 0.5rem;
|
||||
border-radius: 0;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.result-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.result-icon-frame {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: none;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--rarity-glow-color, var(--color-rarity-fallback)) 30%, transparent),
|
||||
color-mix(in srgb, var(--rarity-glow-color, var(--color-rarity-fallback)) 10%, transparent)
|
||||
);
|
||||
border-radius: 0;
|
||||
padding: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.result-icon-frame::before,
|
||||
.todo-icon-frame::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.result-icon-frame > img,
|
||||
.todo-icon-frame > img {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.todo-icon-frame {
|
||||
position: relative;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: none;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--rarity-glow-color, var(--color-rarity-fallback)) 25%, transparent),
|
||||
color-mix(in srgb, var(--rarity-glow-color, var(--color-rarity-fallback)) 10%, transparent)
|
||||
);
|
||||
border-radius: 0;
|
||||
padding: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
margin-right: auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.upgrade-tag {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: var(--ui-accent);
|
||||
}
|
||||
|
||||
.results button:hover {
|
||||
background: var(--color-hover);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.upgrade-levels {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.35rem;
|
||||
overflow-x: auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.level-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ui-accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.level-button {
|
||||
width: 28px !important;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
background: var(--ui-dark-elevated);
|
||||
color: var(--color-text-main);
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
max-width: 28px;
|
||||
padding: 0;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
flex: 0 0 28px;
|
||||
}
|
||||
|
||||
.level-button:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.level-button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
padding: 0.65rem;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--ui-dark-elevated);
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.todo-item:hover {
|
||||
background: var(--color-hover);
|
||||
border-color: var(--color-border);
|
||||
box-shadow: inset 0 0 0 1px var(--color-hover);
|
||||
}
|
||||
|
||||
.minimal-item {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.minimal-item:hover {
|
||||
background: var(--color-hover);
|
||||
border-color: var(--color-border);
|
||||
box-shadow: inset 0 0 0 1px var(--color-hover);
|
||||
}
|
||||
|
||||
.minimal-qty {
|
||||
color: var(--ui-accent);
|
||||
font-weight: 700;
|
||||
min-width: 52px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.todo-item.highlighted {
|
||||
animation: todo-highlight 1.5s ease-out;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.quantity-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.quantity-field input {
|
||||
width: 72px;
|
||||
text-align: center;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.quantity-field input::-webkit-outer-spin-button,
|
||||
.quantity-field input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qty-arrow {
|
||||
width: var(--control-height);
|
||||
height: var(--control-height);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
background: var(--ui-dark-elevated);
|
||||
color: var(--color-text-main);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.qty-arrow:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.todo-item.done .item-name {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-done);
|
||||
}
|
||||
|
||||
.todo-item.done .minimal-qty,
|
||||
.todo-item.done .quantity-field input {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-done);
|
||||
}
|
||||
|
||||
.todo-item.done {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@keyframes todo-highlight {
|
||||
0% {
|
||||
background: color-mix(in srgb, var(--highlight-color, var(--color-rarity-fallback)) 24%, var(--ui-dark-elevated));
|
||||
}
|
||||
35% {
|
||||
background: color-mix(in srgb, var(--highlight-color, var(--color-rarity-fallback)) 16%, var(--ui-dark-elevated));
|
||||
}
|
||||
100% {
|
||||
background: var(--ui-dark-elevated);
|
||||
}
|
||||
}
|
||||
|
||||
.delete {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--ui-dark-elevated);
|
||||
color: var(--color-text-main);
|
||||
border-radius: 0;
|
||||
height: var(--control-height);
|
||||
min-height: var(--control-height);
|
||||
padding: 0 0.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.delete:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--ui-accent);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.legal-footer {
|
||||
margin-top: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--ui-dark-elevated);
|
||||
padding: 0.55rem 0.65rem;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.legal-section {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.legal-section h2 {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.legal-section p {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.legal-section a {
|
||||
color: var(--ui-accent);
|
||||
}
|
||||
|
||||
.legal-section a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.header-top {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.minimal-item {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
}
|
||||
|
||||
.quantity-field,
|
||||
.delete {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
43
frontend/src/types.ts
Normal file
43
frontend/src/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export interface SearchItem {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
iconUrl: string;
|
||||
rarity?: string;
|
||||
}
|
||||
|
||||
export interface UpgradeSalvageItem {
|
||||
slug: string;
|
||||
amount: number;
|
||||
name: string;
|
||||
iconUrl: string;
|
||||
rarity?: string;
|
||||
}
|
||||
|
||||
export interface UpgradeLevel {
|
||||
level: number;
|
||||
salvage: UpgradeSalvageItem[];
|
||||
}
|
||||
|
||||
export interface UpgradeSearchResult {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
iconUrl: string;
|
||||
factionName: string;
|
||||
factionColor: string;
|
||||
levels: UpgradeLevel[];
|
||||
isUpgrade: true;
|
||||
}
|
||||
|
||||
export type SearchResult = SearchItem | UpgradeSearchResult;
|
||||
|
||||
export interface TodoItem {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
iconUrl: string;
|
||||
rarity?: string;
|
||||
quantity: number;
|
||||
completed: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user