This commit is contained in:
2026-03-26 11:33:03 +01:00
commit 1f20359fa3
30 changed files with 7849 additions and 0 deletions

13
frontend/index.html Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Binary file not shown.

814
frontend/src/App.tsx Normal file
View 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
View 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
View 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
View 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
View 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;
}