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
18 changes: 11 additions & 7 deletions src/components/CategoryNav.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
'use client';

import { useRouter } from 'next/navigation';
import { allThemes } from '@/lib/filters';
import { facetOptions } from '@/lib/filters';

const CATEGORIES = allThemes();
const CATEGORIES = facetOptions('theme');

/** Event other components can listen for to apply a theme filter. */
export const FILTER_THEME_EVENT = 'sparky:filter-theme';
Expand Down Expand Up @@ -42,14 +42,18 @@ export default function CategoryNav() {
<span className="shrink-0 font-semibold uppercase tracking-wide text-ink/40">
Categories
</span>
{CATEGORIES.map((category) => (
{CATEGORIES.map((cat) => (
<button
key={category}
key={cat.value}
type="button"
onClick={() => handleClick(category)}
className="shrink-0 cursor-pointer whitespace-nowrap rounded-full px-2.5 py-1 font-medium text-ink/70 transition-colors hover:bg-spark/15 hover:text-spark-deep"
title={cat.value}
onClick={() => handleClick(cat.value)}
className="flex shrink-0 cursor-pointer items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-1 font-medium text-ink/70 transition-colors hover:bg-spark/15 hover:text-spark-deep"
>
{category}
{cat.short}
<span className="rounded-full bg-ink/5 px-1.5 text-[10px] font-bold tabular-nums text-ink/40">
{cat.count}
</span>
</button>
))}
</nav>
Expand Down
56 changes: 35 additions & 21 deletions src/components/FilterPills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import Pill from './Pill';
import {
allThemes,
countActive,
facetOptions,
type ActiveFilters,
type FilterGroup,
} from '@/lib/filters';
Expand All @@ -14,32 +14,46 @@ interface FilterPillsProps {
onClear: () => void;
}

// Browsing is filtered by theme only — genre and era pills were removed to
// keep the bar focused and uncluttered.
const THEMES = allThemes();
// Three facet groups, each precomputed with one-word labels + book counts.
const GROUPS: { key: 'theme' | 'genre' | 'award'; label: string }[] = [
{ key: 'theme', label: 'Theme' },
{ key: 'genre', label: 'Genre' },
{ key: 'award', label: 'Awards' },
];

/** The theme pill-box filter bar. Multi-select. */
const OPTIONS = {
theme: facetOptions('theme'),
genre: facetOptions('genre'),
award: facetOptions('award'),
};

/** The filter bar: grouped, space-filling grids of one-word pills with counts. */
export default function FilterPills({ active, onToggle, onClear }: FilterPillsProps) {
const activeCount = countActive(active);

return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
<span className="w-16 shrink-0 pt-1.5 text-xs font-semibold uppercase tracking-wide text-ink/50">
Theme
</span>
<div className="flex flex-wrap gap-2">
{THEMES.map((value) => (
<Pill
key={value}
active={active.theme.includes(value)}
onClick={() => onToggle('theme', value)}
>
{value}
</Pill>
))}
<div className="space-y-5">
{GROUPS.map((group) => (
<div key={group.key}>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink/50">
{group.label}
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
{OPTIONS[group.key].map((opt) => (
<Pill
key={opt.value}
block
count={opt.count}
title={opt.value}
active={active[group.key].includes(opt.value)}
onClick={() => onToggle(group.key, opt.value)}
>
{opt.short}
</Pill>
))}
</div>
</div>
</div>
))}

{activeCount > 0 && (
<button
Expand Down
36 changes: 31 additions & 5 deletions src/components/Pill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,44 @@ interface PillProps {
/** Small, muted variant for inline tags on cards / guides. */
size?: 'sm' | 'md';
title?: string;
/** Optional count badge shown on the right (used by the filter bar). */
count?: number;
/** Stretch to fill the grid cell and push the count to the right edge. */
block?: boolean;
}

/**
* A pill box. Renders as a toggle button when `onClick` is provided (used by
* the filter bar), otherwise as a static tag.
*/
export default function Pill({ children, active, onClick, size = 'md', title }: PillProps) {
const base =
'inline-flex items-center gap-1 rounded-full border font-medium transition-colors whitespace-nowrap';
export default function Pill({
children,
active,
onClick,
size = 'md',
title,
count,
block,
}: PillProps) {
const base = `inline-flex items-center gap-1.5 rounded-full border font-medium transition-colors ${
block ? 'w-full justify-between' : 'whitespace-nowrap'
}`;
const sizing = size === 'sm' ? 'px-2.5 py-0.5 text-xs' : 'px-3.5 py-1.5 text-sm';
const state = active
? 'border-spark-deep bg-spark text-ink shadow-sm'
: 'border-ink/15 bg-white/70 text-ink/70 hover:border-spark hover:text-ink';

const badge =
count !== undefined ? (
<span
className={`ml-1 rounded-full px-1.5 text-[11px] font-bold tabular-nums ${
active ? 'bg-ink/15 text-ink' : 'bg-ink/5 text-ink/50'
}`}
>
{count}
</span>
) : null;

if (onClick) {
return (
<button
Expand All @@ -30,7 +54,8 @@ export default function Pill({ children, active, onClick, size = 'md', title }:
title={title}
className={`${base} ${sizing} ${state} cursor-pointer`}
>
{children}
<span className="truncate">{children}</span>
{badge}
</button>
);
}
Expand All @@ -40,7 +65,8 @@ export default function Pill({ children, active, onClick, size = 'md', title }:
title={title}
className={`${base} ${sizing} border-ink/10 bg-ink/5 text-ink/70`}
>
{children}
<span className="truncate">{children}</span>
{badge}
</span>
);
}
Loading
Loading