diff --git a/.github/workflows/deploy-cloudflare-pages.yml b/.github/workflows/deploy-cloudflare-pages.yml new file mode 100644 index 00000000..c4bfcca3 --- /dev/null +++ b/.github/workflows/deploy-cloudflare-pages.yml @@ -0,0 +1,35 @@ +name: Deploy Cloudflare Pages + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build site + run: npm run build + + - name: Deploy to Cloudflare Pages + run: npx wrangler@4 pages deploy build --project-name=ror-killboard-preview + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/package.json b/package.json index 074c4c7e..bd54a6d9 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "vite": "^8.0.14" }, "scripts": { + "dev": "vite", "start": "vite", "build": "vite build", "test": "tsc -p . && vite build && oxlint --type-aware src", diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 00000000..7797f7c6 --- /dev/null +++ b/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/src/App.tsx b/src/App.tsx index 6547e0f6..d379a32f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import './i18n/config'; import React from 'react'; import { Character } from '@/pages/Character'; import { Home } from '@/pages/Home'; +import { MainNav } from '@/components/global/MainNav'; import { Kill } from '@/pages/Kill'; import { Guild } from '@/pages/Guild'; import { Search } from '@/pages/Search'; @@ -54,103 +55,121 @@ const App = () => { usePageViews(); return ( - - } /> - } /> - } /> - } /> - } /> - } /> - } - /> - } - /> - } - /> - } - /> - - } /> - } /> - } /> - } - /> - } /> - - } /> - } /> - } /> - - } /> - } /> - } /> - } - /> - } - /> - - } /> - } /> - - } /> - } /> - - } /> - } /> - } - /> - } /> - } /> - } /> - } /> - } - /> - } /> - } /> - - } /> - } /> - } /> - } /> - - } /> - } /> - } - /> - - } /> - } /> - - } /> - } /> - } /> - } - /> - - } /> - + <> + + + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } + /> + } + /> + } + /> + + } /> + } /> + } + /> + } + /> + } /> + + } /> + } /> + } /> + + } /> + } /> + } + /> + } + /> + } + /> + + } /> + } /> + + } /> + } /> + + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } + /> + } + /> + } /> + + } /> + } /> + } /> + } /> + + } /> + } /> + } + /> + + } /> + } + /> + + } /> + } /> + } + /> + } + /> + + } /> + + ); }; diff --git a/src/components/WarIcon.tsx b/src/components/WarIcon.tsx index dd989c88..ec207ef7 100644 --- a/src/components/WarIcon.tsx +++ b/src/components/WarIcon.tsx @@ -1,4 +1,5 @@ import type { ReactElement } from 'react'; +import { assetUrl } from '@/utils'; export const WarIcon = ({ icon, @@ -19,18 +20,18 @@ export const WarIcon = ({ switch (frameType) { case 'circle': { return selected - ? '/images/icons/round_frame_press.png' - : '/images/icons/round_frame.png'; + ? assetUrl('/images/icons/round_frame_press.png') + : assetUrl('/images/icons/round_frame.png'); } case 'hex': { return selected - ? '/images/icons/hex_frame_press.png' - : '/images/icons/hex_frame.png'; + ? assetUrl('/images/icons/hex_frame_press.png') + : assetUrl('/images/icons/hex_frame.png'); } default: { return selected - ? '/images/icons/square_frame_press.png' - : '/images/icons/square_frame.png'; + ? assetUrl('/images/icons/square_frame_press.png') + : assetUrl('/images/icons/square_frame.png'); } } })(); diff --git a/src/components/ZoneHeatmap.tsx b/src/components/ZoneHeatmap.tsx index 8884ce00..e9f7ec64 100644 --- a/src/components/ZoneHeatmap.tsx +++ b/src/components/ZoneHeatmap.tsx @@ -6,7 +6,7 @@ export const ZoneHeatmap = ({ zoneId, max, data, - size = 640, + size, }: { zoneId: string; max: number; diff --git a/src/components/creature/VendorItems.tsx b/src/components/creature/VendorItems.tsx index 167057bb..eef40cb9 100644 --- a/src/components/creature/VendorItems.tsx +++ b/src/components/creature/VendorItems.tsx @@ -1,23 +1,22 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router'; import { gql } from '@apollo/client'; -import { useQuery } from '@apollo/client/react'; +import { useApolloClient } from '@apollo/client/react'; +import { useEffect, useState } from 'react'; import type { Query } from '@/__generated__/graphql'; import { ErrorMessage } from '@/components/global/ErrorMessage'; -import { QueryPagination } from '@/components/global/QueryPagination'; import { GoldPrice } from '@/components/GoldPrice'; const VENDOR_ITEMS = gql` query GetVendorItemsFromCreature( $creatureId: ID! $first: Int - $last: Int - $before: String $after: String ) { creature(id: $creatureId) { id - vendorItems(first: $first, last: $last, before: $before, after: $after) { + vendorItems(first: $first, after: $after) { + totalCount nodes { count item { @@ -38,93 +37,228 @@ const VENDOR_ITEMS = gql` pageInfo { hasNextPage endCursor - hasPreviousPage - startCursor } } } } `; +type VendorItemsConnectionType = NonNullable['vendorItems']; +type VendorItemNode = NonNullable< + NonNullable['nodes'] +>[number]; + +// The API caps a single page at 50 items, and offers no server-side name +// filter on a creature's vendor list. Some vendors sell 100-200+ items, +// which used to mean clicking "Next" a dozen+ times to browse the whole +// catalog. Instead we page through everything up front (a handful of +// 50-item requests even for the biggest vendors) into local state, then +// filter and scroll client-side. export const VendorItems = ({ creatureId, }: { creatureId: string | undefined; }) => { - const perPage = 10; + const perPage = 50; const { t } = useTranslation(['common', 'components']); - const { loading, error, data, refetch } = useQuery(VENDOR_ITEMS, { - variables: { - creatureId, - first: perPage, - }, - }); + const client = useApolloClient(); + const [search, setSearch] = useState(''); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(); + const [hasDataIssue, setHasDataIssue] = useState(false); + + useEffect(() => { + let cancelled = false; + let totalCount: number | undefined; + const controller = new AbortController(); + + // Cursors from this API are just base64-encoded zero-based offsets + // (e.g. offset 49 -> btoa("49")), which lets us request an arbitrary + // [start, start + count) window directly instead of only being able to + // walk forward page by page. + const encodeOffset = (offset: number): string | undefined => + offset > 0 ? btoa(String(offset - 1)) : undefined; + + // Fetch a window of `count` items starting at `start`. If the window + // comes back null (one malformed row poisons the whole array under + // GraphQL's non-null propagation rules), split it in half and retry + // each half independently. This isolates the exact bad row(s) instead + // of discarding the entire 50-item page they happened to land on. + const fetchRange = async ( + start: number, + count: number, + ): Promise => { + if (count <= 0) { + return []; + } + const result = await client.query({ + context: { fetchOptions: { signal: controller.signal } }, + errorPolicy: 'all', + fetchPolicy: 'cache-first', + query: VENDOR_ITEMS, + variables: { after: encodeOffset(start), creatureId, first: count }, + }); + const connection = result.data?.creature?.vendorItems; + if (!connection) { + return []; + } + if (totalCount === undefined) { + totalCount = connection.totalCount; + if (!cancelled) { + setTotal(connection.totalCount); + } + } + if (connection.nodes) { + return connection.nodes; + } + if (count === 1) { + // Narrowed down to a single unrecoverable row. + if (!cancelled) { + setHasDataIssue(true); + } + return []; + } + const half = Math.ceil(count / 2); + const left = await fetchRange(start, half); + const right = await fetchRange(start + half, count - half); + return [...left, ...right]; + }; + + const loadAll = async (): Promise => { + setLoading(true); + setLoadError(undefined); + setHasDataIssue(false); + setItems([]); + let offset = 0; + const accumulated: VendorItemNode[] = []; + + try { + do { + const nodes = await fetchRange(offset, perPage); + accumulated.push(...nodes); + offset += perPage; + if (!cancelled) { + setItems([...accumulated]); + } + if (totalCount === undefined || offset >= totalCount) { + break; + } + } while (!cancelled); + } catch (caughtError) { + if (!cancelled && !controller.signal.aborted) { + setLoadError( + caughtError instanceof Error + ? caughtError + : new Error('Unable to load vendor items.'), + ); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void loadAll(); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [client, creatureId]); - if (loading) { + if (loading && items.length === 0) { return ; } - if (error) { - return ; + if (loadError) { + return ; } - - const vendorItems = data?.creature?.vendorItems; - - if (vendorItems?.nodes == null) { + if (items.length === 0) { return ; } - if (vendorItems?.nodes == null) { - return null; - } - - const { pageInfo } = vendorItems; + const filtered = search + ? items.filter((vendorItem) => + vendorItem.item.name.toLowerCase().includes(search.toLowerCase()), + ) + : items; return ( <> - - - - - - - - - {vendorItems.nodes.map((vendorItem) => ( - - - + + + ))} + +
{t('components:itemVendors.item')}{t('components:itemVendors.price')}
- -
- Item Icon -
- - {vendorItem.item.name} - - x{vendorItem.count} -
-
- - {vendorItem.requiredItems.map((requiredItem) => ( - +
+ +
+ {loading && ( +

+ Gathering {items.length} of {total || '…'} items… +

+ )} + {hasDataIssue && ( +

+ Some items could not be loaded due to a data issue and are missing + from this list. +

+ )} +
+ + + + + + + + + {filtered.map((vendorItem) => ( + + - - ))} - -
{t('components:itemVendors.item')}{t('components:itemVendors.price')}
+
- Item Icon + Item Icon
- - {requiredItem.item.name} + + {vendorItem.item.name} - x{requiredItem.count} + x{vendorItem.count}
- ))} -
- +
+ + {vendorItem.requiredItems.map((requiredItem) => ( + +
+ Item Icon +
+ + {requiredItem.item.name} + + x{requiredItem.count} +
+ ))} +
+ {filtered.length === 0 && ( +

{t('common:noResults')}

+ )} + ); }; diff --git a/src/components/global/MainNav.tsx b/src/components/global/MainNav.tsx new file mode 100644 index 00000000..63e38f64 --- /dev/null +++ b/src/components/global/MainNav.tsx @@ -0,0 +1,49 @@ +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; +import { Link, useLocation } from 'react-router'; +import type { ReactElement } from 'react'; + +// Persistent top nav, rendered on every page (see App.tsx). Nav links are +// alphabetical by label. Items, Quests, Creatures, Instances, and +// Storylines each have their own route and full page layout; the +// ranked leaderboard page intentionally isn't added here. +export const MainNav = (): ReactElement => { + const { t } = useTranslation(); + const { pathname } = useLocation(); + + return ( +
+
+
  • + {t('pages:home.showCreatures')} +
  • +
  • + {t('pages:home.showGuildLeaderboard')} +
  • +
  • + {t('pages:home.showInstances')} +
  • +
  • + {t('pages:home.showItems')} +
  • +
  • + {t('pages:home.showPlayerLeaderboard')} +
  • +
  • + {t('pages:home.showQuests')} +
  • +
  • + {t('pages:home.showScenarios')} +
  • +
  • + {t('pages:home.showSkirmishes')} +
  • +
  • + {t('pages:home.showStorylines')} +
  • +
    +
    + ); +}; diff --git a/src/components/global/SearchBox.tsx b/src/components/global/SearchBox.tsx index f6e23812..857db8c3 100644 --- a/src/components/global/SearchBox.tsx +++ b/src/components/global/SearchBox.tsx @@ -1,39 +1,125 @@ import type { ReactElement } from 'react'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; +const DEBOUNCE_MS = 300; + export const SearchBox = ({ initialQuery, onSubmit, isPlayer, + navigateOnSubmit, }: { initialQuery?: string; onSubmit?: (query: string) => void; isPlayer?: boolean; + navigateOnSubmit?: boolean; }): ReactElement => { const { t } = useTranslation('components'); const navigate = useNavigate(); const [query, setQuery] = useState(initialQuery ?? ''); + const debounceRef = useRef>(undefined); + const inputRef = useRef(null); + // Tracks the last value *this component* pushed out via commit(), so the + // effect below can tell "the URL changed because we just typed and our + // own debounced commit landed" apart from "the URL changed for some + // other reason (browser back/forward, parent reset)". Without that + // distinction, every commit's resulting initialQuery update would + // immediately feed back in and could clobber a keystroke (e.g. + // backspace) typed in the gap before that round trip resolved. + const lastCommittedRef = useRef(initialQuery ?? ''); + + useEffect(() => { + const next = initialQuery ?? ''; + if (next !== lastCommittedRef.current) { + lastCommittedRef.current = next; + setQuery(next); + } + }, [initialQuery]); + + useEffect(() => { + return () => { + clearTimeout(debounceRef.current); + }; + }, []); + + // navigateOnSubmit boxes (Home's player/guild tabs, plus the dedicated + // Search/SearchGuild pages) can go from one page to a completely + // different one mid-search: committing a debounced keystroke navigates + // from e.g. /guilds to /search/guild/:query, which swaps in a whole new + // route element and mounts a brand-new input. The old input's focus + // goes with it, so without this the user has to click back into the + // box to keep typing. Runs once per mount, which lines up with exactly + // that one moment - staying on the same search results page for + // further keystrokes only updates the URL param, it doesn't remount. + useEffect(() => { + if (!navigateOnSubmit) { + return; + } + const input = inputRef.current; + if (!input) { + return; + } + input.focus(); + const end = input.value.length; + input.setSelectionRange(end, end); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const commit = (value: string): void => { + clearTimeout(debounceRef.current); + lastCommittedRef.current = value; + // navigateOnSubmit is only used by the dedicated player/guild search + // pages, which are keyed off the query in the URL (/search/:query and + // /search/guild/:query). Every other caller (Creatures, Items, Quests, + // Instances, ...) just wants the callback -- it must NOT navigate, + // since that used to unconditionally send users to guild search. + // + // An empty value gets its own destination instead of navigating to + // `/search/guild/` (trailing empty segment). There's no route for + // that exact shape, and bare `/search/guild` actually matches the + // player route `/search/:query` with query="guild" - landing back on + // this box showing the literal word "guild" instead of clearing. + if (navigateOnSubmit) { + if (value) { + void navigate( + isPlayer ? `/search/${value}` : `/search/guild/${value}`, + { replace: true }, + ); + } else { + void navigate(isPlayer ? '/' : '/guilds', { replace: true }); + } + } + if (onSubmit) { + onSubmit(value); + } + }; return (
    { e.preventDefault(); - void navigate(isPlayer ? `/search/${query}` : `/search/guild/${query}`); - if (onSubmit) { - onSubmit(query); - } + commit(query); }} >

    setQuery(e.target.value)} + value={query} + onChange={(e) => { + const { value } = e.target; + setQuery(value); + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout( + () => commit(value), + DEBOUNCE_MS, + ); + }} /> diff --git a/src/components/item/ItemVendorsFilters.tsx b/src/components/item/ItemVendorsFilters.tsx index 4bb10b7a..ac561d96 100644 --- a/src/components/item/ItemVendorsFilters.tsx +++ b/src/components/item/ItemVendorsFilters.tsx @@ -26,101 +26,95 @@ export const ItemVendorsFilters = (): ReactElement => { const career = search.get('career') || 'all'; return ( -

    -
    -
    -
    -
    - - -
    -
    +
    +
    +
    ); }; diff --git a/src/components/kill/KillsList.tsx b/src/components/kill/KillsList.tsx index 27e738a2..3c709fa3 100644 --- a/src/components/kill/KillsList.tsx +++ b/src/components/kill/KillsList.tsx @@ -13,7 +13,7 @@ export const KillsList = ({ query, queryOptions, perPage, - title = undefined, + title, showTime = true, showVictim = true, showKiller = true, diff --git a/src/components/scenario/CharacterScenarioConnections.tsx b/src/components/scenario/CharacterScenarioConnections.tsx new file mode 100644 index 00000000..8ef9519c --- /dev/null +++ b/src/components/scenario/CharacterScenarioConnections.tsx @@ -0,0 +1,427 @@ +import { gql } from '@apollo/client'; +import { useApolloClient } from '@apollo/client/react'; +import { type ReactElement, useEffect, useMemo, useState } from 'react'; +import { Link, useSearchParams } from 'react-router'; +import { + type Career, + type Kill, + type Query, + type ScenarioRecord, +} from '@/__generated__/graphql'; +import { CareerIcon } from '@/components/CareerIcon'; + +const CHARACTER_SCENARIO_DEATHS = gql` + query GetCharacterScenarioDeaths( + $where: KillFilterInput + $first: Int + $after: String + ) { + kills(where: $where, first: $first, after: $after) { + nodes { + id + deathblow { + id + name + career + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +`; + +interface ConnectionPlayer { + career: Career; + id: string; + losses: number; + matches: number; + name: string; + wins: number; +} + +const dateInputValue = (date: Date): string => { + const offsetDate = new Date( + date.getTime() - date.getTimezoneOffset() * 60 * 1000, + ); + return offsetDate.toISOString().slice(0, 10); +}; + +const ConnectionTable = ({ + emptyText, + killsOnly = false, + players, + title, +}: { + emptyText: string; + killsOnly?: boolean; + players: ConnectionPlayer[]; + title: string; +}): ReactElement => ( +
    +
    +

    {title}

    + {players.length} players +
    + {players.length === 0 ? ( +

    {emptyText}

    + ) : ( +
    + + + + + + {!killsOnly && ( + <> + + + + + )} + + + + {players.slice(0, 10).map((player) => ( + + + + + {!killsOnly && ( + <> + + + + + )} + + ))} + +
    + Character{killsOnly ? 'Killing blows' : 'Matches'}WLWR
    + + + {player.name} + {player.matches}{player.wins}{player.losses} + {Math.round((player.wins / player.matches) * 100)}% +
    +
    + )} +
    +); + +export const CharacterScenarioConnections = ({ + characterId, + scenarios, +}: { + characterId: string; + scenarios: ScenarioRecord[]; +}): ReactElement => { + const [searchParams, setSearchParams] = useSearchParams(); + const client = useApolloClient(); + const [deathblows, setDeathblows] = useState([]); + const [deathblowsLoading, setDeathblowsLoading] = useState(false); + const queueType = searchParams.get('queue_type') ?? 'all'; + const tier = searchParams.get('tier') ?? 'all'; + const range = searchParams.get('range') ?? 'recent'; + const scenarioIds = scenarios.map((scenario) => scenario.id); + const scenarioIdsKey = scenarioIds.join(','); + + const updateParams = (updates: Record): void => { + const next = new URLSearchParams(searchParams); + for (const [key, value] of Object.entries(updates)) { + if (value === undefined) { + next.delete(key); + } else { + next.set(key, value); + } + } + next.delete('lbRealm'); + next.delete('lbRole'); + next.delete('lbCareer'); + next.delete('lbMetric'); + next.delete('lbLimit'); + next.delete('lbMin'); + next.delete('lbMode'); + setSearchParams(next, { replace: true }); + }; + + useEffect(() => { + let cancelled = false; + const loadDeathblows = async (): Promise => { + setDeathblows([]); + if (scenarioIds.length === 0) { + return; + } + setDeathblowsLoading(true); + const loaded: Kill[] = []; + try { + for (let index = 0; index < scenarioIds.length; index += 100) { + const instanceIds = scenarioIds.slice(index, index + 100); + let after: string | undefined; + do { + const result = await client.query({ + errorPolicy: 'all', + fetchPolicy: 'network-only', + query: CHARACTER_SCENARIO_DEATHS, + variables: { + after, + first: 50, + where: { + instanceId: { in: instanceIds }, + victimCharacterId: { eq: characterId }, + }, + }, + }); + const connection = result.data?.kills; + // A malformed row nulls out the whole `nodes` array under + // GraphQL's non-null propagation rules; skip it rather than + // losing the request (or the whole panel) to a thrown error. + loaded.push(...(connection?.nodes ?? [])); + after = connection?.pageInfo.endCursor ?? undefined; + if (!connection?.pageInfo.hasNextPage || !after) { + break; + } + } while (!cancelled); + if (cancelled) { + break; + } + } + if (!cancelled) { + setDeathblows(loaded); + } + } catch { + // This panel is supplementary (deathblow breakdown within a + // character's scenario history) -- on failure, keep whatever we + // already gathered instead of silently discarding it and leaving + // the section looking empty. + if (!cancelled) { + setDeathblows(loaded); + } + } finally { + if (!cancelled) { + setDeathblowsLoading(false); + } + } + }; + void loadDeathblows(); + return () => { + cancelled = true; + }; + }, [characterId, client, scenarioIdsKey]); + + const { opponents, teammates } = useMemo(() => { + const teammateMap = new Map(); + const opponentMap = new Map(); + + for (const scenario of scenarios) { + const ownEntry = scenario.scoreboardEntries.find( + (entry) => entry.character.id === characterId, + ); + if (!ownEntry) { + continue; + } + const won = scenario.winner === ownEntry.team; + for (const entry of scenario.scoreboardEntries) { + if (entry.character.id === characterId) { + continue; + } + const target = entry.team === ownEntry.team ? teammateMap : opponentMap; + const current = target.get(entry.character.id) ?? { + career: entry.character.career, + id: entry.character.id, + losses: 0, + matches: 0, + name: entry.character.name, + wins: 0, + }; + current.matches += 1; + current.wins += won ? 1 : 0; + current.losses += won ? 0 : 1; + target.set(entry.character.id, current); + } + } + const sortPlayers = (players: ConnectionPlayer[]) => + players.toSorted( + (left, right) => + right.matches - left.matches || + right.wins - left.wins || + left.name.localeCompare(right.name), + ); + return { + opponents: sortPlayers([...opponentMap.values()]), + teammates: sortPlayers([...teammateMap.values()]), + }; + }, [characterId, scenarios]); + + const killers = useMemo(() => { + const players = new Map(); + for (const kill of deathblows) { + if (!kill.deathblow) { + continue; + } + const current = players.get(kill.deathblow.id) ?? { + career: kill.deathblow.career, + id: kill.deathblow.id, + losses: 0, + matches: 0, + name: kill.deathblow.name, + wins: 0, + }; + current.matches += 1; + current.losses += 1; + players.set(kill.deathblow.id, current); + } + return [...players.values()].toSorted( + (left, right) => + right.matches - left.matches || left.name.localeCompare(right.name), + ); + }, [deathblows]); + + return ( + <> +
    + + + + {range === 'custom' && ( + <> + + + + )} +