Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions frontend/app/cards/CardsClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import CardGrid from "../components/CardGrid";
import FullCardGrid from "../components/FullCardGrid";
import SearchFilter from "../components/SearchFilter";
import { useLanguage } from "../contexts/LanguageContext";
import { t } from "@/lib/ui-translations";
import { CDN_BASE } from "@/lib/image-url";
import { useChannel, useLangPrefix } from "@/lib/use-lang-prefix";
import { useEntityScores } from "@/lib/use-entity-scores";
import { useBetaAdditions } from "@/lib/use-beta-additions";
Expand Down Expand Up @@ -52,18 +54,23 @@ const rarityOptions = [
{ label: "Quest", value: "Quest" },
];

// The game's blank energy orb marks energy costs and the star icon marks
// Ancient star costs (served from the CDN), so the two groups can't be
// confused; the group names are translated at render time.
const ENERGY_ICON = `${CDN_BASE}/icons/colorless_energy_icon.webp`;
const STAR_ICON = `${CDN_BASE}/icons/star_icon.webp`;
const costOptions = [
{ label: "0", value: "0", group: "Energy" },
{ label: "1", value: "1", group: "Energy" },
{ label: "2", value: "2", group: "Energy" },
{ label: "3", value: "3", group: "Energy" },
{ label: "4+", value: "4plus", group: "Energy" },
{ label: "X", value: "x", group: "Energy" },
{ label: "1", value: "star1", group: "Star" },
{ label: "2", value: "star2", group: "Star" },
{ label: "3", value: "star3", group: "Star" },
{ label: "4+", value: "star4plus", group: "Star" },
{ label: "X", value: "starx", group: "Star" },
{ label: "0", value: "0", group: "Energy", icon: ENERGY_ICON },
{ label: "1", value: "1", group: "Energy", icon: ENERGY_ICON },
{ label: "2", value: "2", group: "Energy", icon: ENERGY_ICON },
{ label: "3", value: "3", group: "Energy", icon: ENERGY_ICON },
{ label: "4+", value: "4plus", group: "Energy", icon: ENERGY_ICON },
{ label: "X", value: "x", group: "Energy", icon: ENERGY_ICON },
{ label: "1", value: "star1", group: "Star", icon: STAR_ICON },
{ label: "2", value: "star2", group: "Star", icon: STAR_ICON },
{ label: "3", value: "star3", group: "Star", icon: STAR_ICON },
{ label: "4+", value: "star4plus", group: "Star", icon: STAR_ICON },
{ label: "X", value: "starx", group: "Star", icon: STAR_ICON },
];

function matchesCost(c: Card, want: string): boolean {
Expand Down Expand Up @@ -264,7 +271,10 @@ function CardsClientInner({ initialCards }: { initialCards: Card[] }) {
label: "Any Cost",
name: "Cost",
value: cost,
options: costOptions,
options: costOptions.map((o) => ({
...o,
group: o.group === "Energy" ? t("Energy", lang) : t("Star Cost", lang),
})),
onChange: (v) => setFilterAndUrl("cost", v, setCost),
},
{
Expand Down
151 changes: 151 additions & 0 deletions frontend/app/components/IconSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"use client";

import { useEffect, useId, useRef, useState } from "react";
import { imageUrl } from "@/lib/image-url";

export interface IconOption {
label: string;
value: string;
group?: string;
icon?: string;
}

interface IconSelectProps {
label: string;
value: string;
options: IconOption[];
onChange: (value: string) => void;
}

/**
* Drop-in replacement for the native filter <select> when options carry
* image icons (native <option> elements are text-only, so the game's energy
* and star icons can't render inside one). Mirrors the filter-select look;
* group headers and rows show the real game asset.
*/
export default function IconSelect({ label, value, options, onChange }: IconSelectProps) {
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const rootRef = useRef<HTMLDivElement>(null);
const listId = useId();

// Flat list with the placeholder first, mirroring the native select's
// empty option; `active` indexes into this.
const flat: IconOption[] = [{ label, value: "" }, ...options];
const selected = options.find((o) => o.value === value);

useEffect(() => {
if (!open) return;
function onDocMouseDown(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onDocMouseDown);
return () => document.removeEventListener("mousedown", onDocMouseDown);
}, [open]);

const pick = (v: string) => {
onChange(v);
setOpen(false);
};

const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
return;
}
if (!open && (e.key === "Enter" || e.key === " " || e.key === "ArrowDown")) {
e.preventDefault();
setOpen(true);
setActive(Math.max(0, flat.findIndex((o) => o.value === value)));
return;
}
if (!open) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const delta = e.key === "ArrowDown" ? 1 : -1;
setActive((a) => Math.min(flat.length - 1, Math.max(0, a + delta)));
} else if (e.key === "Home") {
e.preventDefault();
setActive(0);
} else if (e.key === "End") {
e.preventDefault();
setActive(flat.length - 1);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (active >= 0) pick(flat[active].value);
}
};

let lastGroup: string | undefined;

return (
<div ref={rootRef} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
onKeyDown={onKeyDown}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
aria-label={label}
className="filter-select inline-flex items-center gap-1.5 px-3 py-2.5 bg-[var(--bg-card)] border border-[var(--border-subtle)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-gold)]/50 cursor-pointer text-sm"
>
{selected?.icon && (
<img src={imageUrl(selected.icon)} alt="" className="w-4 h-4" crossOrigin="anonymous" />
)}
<span>{selected ? selected.label : label}</span>
<svg className="w-3 h-3 opacity-70" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>

{open && (
<div
id={listId}
role="listbox"
aria-label={label}
className="absolute left-0 top-full mt-1 min-w-full w-max rounded-lg border border-[var(--border-subtle)] bg-[var(--bg-primary)] shadow-xl shadow-black/30 z-50 py-1"
>
{flat.map((opt, i) => {
const header =
opt.group && opt.group !== lastGroup ? (
<div
key={`g-${opt.group}`}
className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)] flex items-center gap-1.5"
>
{opt.icon && (
<img src={imageUrl(opt.icon)} alt="" className="w-3.5 h-3.5" crossOrigin="anonymous" />
)}
{opt.group}
</div>
) : null;
lastGroup = opt.group;
const isSelected = opt.value === value;
return (
<div key={opt.value || "any"}>
{header}
<button
type="button"
role="option"
aria-selected={isSelected}
onClick={() => pick(opt.value)}
onMouseEnter={() => setActive(i)}
className={`w-full text-left px-3 py-1.5 text-sm flex items-center gap-2 cursor-pointer ${
i === active ? "bg-[var(--bg-card)]" : ""
} ${isSelected ? "text-[var(--accent-gold)]" : "text-[var(--text-primary)]"}`}
>
{opt.icon && (
<img src={imageUrl(opt.icon)} alt="" className="w-4 h-4" crossOrigin="anonymous" />
)}
{opt.label}
</button>
</div>
);
})}
</div>
)}
</div>
);
}
13 changes: 13 additions & 0 deletions frontend/app/components/SearchFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
import { useEffect, useRef, useState } from "react";
import { useLanguage } from "@/app/contexts/LanguageContext";
import { t } from "@/lib/ui-translations";
import IconSelect from "./IconSelect";

interface FilterOption {
label: string;
value: string;
group?: string;
// Game-asset icon URL. Options with icons render through IconSelect,
// since native <option> elements can't contain images.
icon?: string;
}

interface SortOption {
Expand Down Expand Up @@ -104,6 +108,14 @@ export default function SearchFilter({
{filters?.map((filter) => (
<label key={filter.label} className="flex flex-col gap-1">
{filter.name && caption(filter.name)}
{filter.options.some((o) => o.icon) ? (
<IconSelect
label={t(filter.label, lang)}
value={filter.value}
options={filter.options}
onChange={filter.onChange}
/>
) : (
<select
value={filter.value}
onChange={(e) => filter.onChange(e.target.value)}
Expand Down Expand Up @@ -133,6 +145,7 @@ export default function SearchFilter({
),
)}
</select>
)}
</label>
))}
{sortOptions && onSortChange && (
Expand Down
2 changes: 1 addition & 1 deletion frontend/lib/image-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function imageUrl(path: string | null | undefined): string {
return `${API}${path}`;
}

const CDN_BASE = CDN_URL || "https://cdn.spire-codex.com";
export const CDN_BASE = CDN_URL || "https://cdn.spire-codex.com";

// The current beta version for render paths ("0.107.0" shape, no leading v).
// The literal here is only a first-paint fallback: SiteSwitcher refreshes it
Expand Down
Loading