feat: Created SortDropdown component and useSortPreference hook, integr…
- "frontend/src/components/SortDropdown.tsx" - "frontend/src/hooks/useSortPreference.ts" - "frontend/src/pages/SearchResults.tsx" - "frontend/src/pages/SubTopicPage.tsx" - "frontend/src/pages/CreatorDetail.tsx" - "frontend/src/api/public-client.ts" - "frontend/src/App.css" GSD-Task: S02/T02
This commit is contained in:
parent
e0c73db8ff
commit
baef500de6
11 changed files with 305 additions and 15 deletions
|
|
@ -11,7 +11,7 @@
|
||||||
- Estimate: 25min
|
- Estimate: 25min
|
||||||
- Files: backend/routers/search.py, backend/routers/topics.py, backend/routers/techniques.py, backend/search_service.py
|
- Files: backend/routers/search.py, backend/routers/topics.py, backend/routers/techniques.py, backend/search_service.py
|
||||||
- Verify: curl tests: `curl 'http://localhost:8001/api/v1/search?q=snare&sort=newest'` returns results in created_at desc order. `curl 'http://localhost:8001/api/v1/topics/sound-design/bass?sort=oldest'` returns oldest first.
|
- Verify: curl tests: `curl 'http://localhost:8001/api/v1/search?q=snare&sort=newest'` returns results in created_at desc order. `curl 'http://localhost:8001/api/v1/topics/sound-design/bass?sort=oldest'` returns oldest first.
|
||||||
- [ ] **T02: SortDropdown component, session persistence hook, and integration into 3 pages** — 1. Create a shared `SortDropdown` component: accepts `options: {value, label}[]`, `value`, `onChange`, `className`. Renders a `<select>` styled consistently with the app's dark theme. Shows the active sort visually.
|
- [x] **T02: Created SortDropdown component and useSortPreference hook, integrated sort controls into SearchResults, SubTopicPage, and CreatorDetail with session-persistent preference** — 1. Create a shared `SortDropdown` component: accepts `options: {value, label}[]`, `value`, `onChange`, `className`. Renders a `<select>` styled consistently with the app's dark theme. Shows the active sort visually.
|
||||||
2. Create a `useSortPreference(defaultSort: string)` hook that reads/writes to `sessionStorage` key `chrysopedia_sort_pref`. Returns `[sort, setSort]`. When setSort is called, it persists to sessionStorage.
|
2. Create a `useSortPreference(defaultSort: string)` hook that reads/writes to `sessionStorage` key `chrysopedia_sort_pref`. Returns `[sort, setSort]`. When setSort is called, it persists to sessionStorage.
|
||||||
3. Add SortDropdown to SearchResults.tsx: options include 'relevance' (default when query active), 'newest', 'oldest', 'alpha', 'creator'. Pass sort param to searchApi().
|
3. Add SortDropdown to SearchResults.tsx: options include 'relevance' (default when query active), 'newest', 'oldest', 'alpha', 'creator'. Pass sort param to searchApi().
|
||||||
4. Add SortDropdown to SubTopicPage.tsx: options 'alpha' (default), 'newest', 'oldest', 'creator'. Pass sort to fetchSubTopicTechniques().
|
4. Add SortDropdown to SubTopicPage.tsx: options 'alpha' (default), 'newest', 'oldest', 'creator'. Pass sort to fetchSubTopicTechniques().
|
||||||
|
|
|
||||||
9
.gsd/milestones/M012/slices/S02/tasks/T01-VERIFY.json
Normal file
9
.gsd/milestones/M012/slices/S02/tasks/T01-VERIFY.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"taskId": "T01",
|
||||||
|
"unitId": "M012/S02/T01",
|
||||||
|
"timestamp": 1775024876034,
|
||||||
|
"passed": true,
|
||||||
|
"discoverySource": "none",
|
||||||
|
"checks": []
|
||||||
|
}
|
||||||
92
.gsd/milestones/M012/slices/S02/tasks/T02-SUMMARY.md
Normal file
92
.gsd/milestones/M012/slices/S02/tasks/T02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
---
|
||||||
|
id: T02
|
||||||
|
parent: S02
|
||||||
|
milestone: M012
|
||||||
|
provides: []
|
||||||
|
requires: []
|
||||||
|
affects: []
|
||||||
|
key_files: ["frontend/src/components/SortDropdown.tsx", "frontend/src/hooks/useSortPreference.ts", "frontend/src/pages/SearchResults.tsx", "frontend/src/pages/SubTopicPage.tsx", "frontend/src/pages/CreatorDetail.tsx", "frontend/src/api/public-client.ts", "frontend/src/App.css"]
|
||||||
|
key_decisions: ["Shared sessionStorage key across all pages for unified sort preference", "Page-specific option lists with context-appropriate defaults"]
|
||||||
|
patterns_established: []
|
||||||
|
drill_down_paths: []
|
||||||
|
observability_surfaces: []
|
||||||
|
duration: ""
|
||||||
|
verification_result: "TypeScript compilation clean (npx tsc --noEmit), Vite build successful (npm run build). Browser verification on ub01:8096: all three pages show sort dropdown, sort changes update results (search alpha reorders alphabetically, subtopic newest reorders by date), sessionStorage.getItem('chrysopedia_sort_pref') returns persisted value after navigation."
|
||||||
|
completed_at: 2026-04-01T06:33:30.555Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T02: Created SortDropdown component and useSortPreference hook, integrated sort controls into SearchResults, SubTopicPage, and CreatorDetail with session-persistent preference
|
||||||
|
|
||||||
|
> Created SortDropdown component and useSortPreference hook, integrated sort controls into SearchResults, SubTopicPage, and CreatorDetail with session-persistent preference
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
---
|
||||||
|
id: T02
|
||||||
|
parent: S02
|
||||||
|
milestone: M012
|
||||||
|
key_files:
|
||||||
|
- frontend/src/components/SortDropdown.tsx
|
||||||
|
- frontend/src/hooks/useSortPreference.ts
|
||||||
|
- frontend/src/pages/SearchResults.tsx
|
||||||
|
- frontend/src/pages/SubTopicPage.tsx
|
||||||
|
- frontend/src/pages/CreatorDetail.tsx
|
||||||
|
- frontend/src/api/public-client.ts
|
||||||
|
- frontend/src/App.css
|
||||||
|
key_decisions:
|
||||||
|
- Shared sessionStorage key across all pages for unified sort preference
|
||||||
|
- Page-specific option lists with context-appropriate defaults
|
||||||
|
duration: ""
|
||||||
|
verification_result: passed
|
||||||
|
completed_at: 2026-04-01T06:33:30.556Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T02: Created SortDropdown component and useSortPreference hook, integrated sort controls into SearchResults, SubTopicPage, and CreatorDetail with session-persistent preference
|
||||||
|
|
||||||
|
**Created SortDropdown component and useSortPreference hook, integrated sort controls into SearchResults, SubTopicPage, and CreatorDetail with session-persistent preference**
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
|
||||||
|
Built a reusable SortDropdown component and useSortPreference hook backed by sessionStorage. Integrated into three pages: SearchResults (relevance/newest/oldest/alpha/creator), SubTopicPage (alpha/newest/oldest/creator), and CreatorDetail (newest/oldest/alpha). Updated public-client.ts to pass sort params to searchApi and fetchSubTopicTechniques. Added dark-theme CSS with custom chevron, focus ring, and creator-techniques header flex layout. Deployed to ub01 and verified all three pages show the dropdown, changing sort reorders results, and preference persists across navigation.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
TypeScript compilation clean (npx tsc --noEmit), Vite build successful (npm run build). Browser verification on ub01:8096: all three pages show sort dropdown, sort changes update results (search alpha reorders alphabetically, subtopic newest reorders by date), sessionStorage.getItem('chrysopedia_sort_pref') returns persisted value after navigation.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
| # | Command | Exit Code | Verdict | Duration |
|
||||||
|
|---|---------|-----------|---------|----------|
|
||||||
|
| 1 | `npx tsc --noEmit` | 0 | ✅ pass | 5100ms |
|
||||||
|
| 2 | `npm run build` | 0 | ✅ pass | 3400ms |
|
||||||
|
| 3 | `browser: search sort dropdown visible and functional` | 0 | ✅ pass | 500ms |
|
||||||
|
| 4 | `browser: subtopic sort dropdown visible` | 0 | ✅ pass | 500ms |
|
||||||
|
| 5 | `browser: creator sort dropdown visible` | 0 | ✅ pass | 500ms |
|
||||||
|
| 6 | `browser: sessionStorage persists across navigation` | 0 | ✅ pass | 500ms |
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `frontend/src/components/SortDropdown.tsx`
|
||||||
|
- `frontend/src/hooks/useSortPreference.ts`
|
||||||
|
- `frontend/src/pages/SearchResults.tsx`
|
||||||
|
- `frontend/src/pages/SubTopicPage.tsx`
|
||||||
|
- `frontend/src/pages/CreatorDetail.tsx`
|
||||||
|
- `frontend/src/api/public-client.ts`
|
||||||
|
- `frontend/src/App.css`
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
None.
|
||||||
|
|
@ -4716,6 +4716,68 @@ a.app-footer__about:hover,
|
||||||
|
|
||||||
/* ── Page-enter transition ────────────────────────────────────────────────── */
|
/* ── Page-enter transition ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* ── Sort Dropdown ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sort-dropdown {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown__label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown__select {
|
||||||
|
appearance: none;
|
||||||
|
background: var(--color-bg-input);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.375rem 2rem 0.375rem 0.625rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238b8b9a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.625rem center;
|
||||||
|
transition: border-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown__select:hover {
|
||||||
|
border-color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown__select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-accent-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown__select option {
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Creator techniques section header with sort */
|
||||||
|
.creator-techniques__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creator-techniques__header .creator-techniques__title {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creator-techniques__header .sort-dropdown {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pageEnter {
|
@keyframes pageEnter {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
|
||||||
|
|
@ -221,10 +221,12 @@ export async function searchApi(
|
||||||
q: string,
|
q: string,
|
||||||
scope?: string,
|
scope?: string,
|
||||||
limit?: number,
|
limit?: number,
|
||||||
|
sort?: string,
|
||||||
): Promise<SearchResponse> {
|
): Promise<SearchResponse> {
|
||||||
const qs = new URLSearchParams({ q });
|
const qs = new URLSearchParams({ q });
|
||||||
if (scope) qs.set("scope", scope);
|
if (scope) qs.set("scope", scope);
|
||||||
if (limit !== undefined) qs.set("limit", String(limit));
|
if (limit !== undefined) qs.set("limit", String(limit));
|
||||||
|
if (sort) qs.set("sort", sort);
|
||||||
return request<SearchResponse>(`${BASE}/search?${qs.toString()}`);
|
return request<SearchResponse>(`${BASE}/search?${qs.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,11 +291,12 @@ export async function fetchTopics(): Promise<TopicCategory[]> {
|
||||||
export async function fetchSubTopicTechniques(
|
export async function fetchSubTopicTechniques(
|
||||||
categorySlug: string,
|
categorySlug: string,
|
||||||
subtopicSlug: string,
|
subtopicSlug: string,
|
||||||
params: { limit?: number; offset?: number } = {},
|
params: { limit?: number; offset?: number; sort?: string } = {},
|
||||||
): Promise<TechniqueListResponse> {
|
): Promise<TechniqueListResponse> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.limit !== undefined) qs.set("limit", String(params.limit));
|
if (params.limit !== undefined) qs.set("limit", String(params.limit));
|
||||||
if (params.offset !== undefined) qs.set("offset", String(params.offset));
|
if (params.offset !== undefined) qs.set("offset", String(params.offset));
|
||||||
|
if (params.sort) qs.set("sort", params.sort);
|
||||||
const query = qs.toString();
|
const query = qs.toString();
|
||||||
return request<TechniqueListResponse>(
|
return request<TechniqueListResponse>(
|
||||||
`${BASE}/topics/${encodeURIComponent(categorySlug)}/${encodeURIComponent(subtopicSlug)}${query ? `?${query}` : ""}`,
|
`${BASE}/topics/${encodeURIComponent(categorySlug)}/${encodeURIComponent(subtopicSlug)}${query ? `?${query}` : ""}`,
|
||||||
|
|
|
||||||
42
frontend/src/components/SortDropdown.tsx
Normal file
42
frontend/src/components/SortDropdown.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* Shared sort dropdown styled for the dark theme.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SortOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortDropdownProps {
|
||||||
|
options: SortOption[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SortDropdown({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: SortDropdownProps) {
|
||||||
|
return (
|
||||||
|
<div className={`sort-dropdown${className ? ` ${className}` : ""}`}>
|
||||||
|
<label className="sort-dropdown__label" htmlFor="sort-select">
|
||||||
|
Sort by
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="sort-select"
|
||||||
|
className="sort-dropdown__select"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/hooks/useSortPreference.ts
Normal file
30
frontend/src/hooks/useSortPreference.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "chrysopedia_sort_pref";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads/writes a sort preference to sessionStorage.
|
||||||
|
* Falls back to `defaultSort` if no stored value exists.
|
||||||
|
*/
|
||||||
|
export function useSortPreference(
|
||||||
|
defaultSort: string,
|
||||||
|
): [string, (next: string) => void] {
|
||||||
|
const [sort, setSortState] = useState<string>(() => {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(STORAGE_KEY) ?? defaultSort;
|
||||||
|
} catch {
|
||||||
|
return defaultSort;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const setSort = useCallback((next: string) => {
|
||||||
|
setSortState(next);
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, next);
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable (private browsing, etc.)
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [sort, setSort];
|
||||||
|
}
|
||||||
|
|
@ -14,9 +14,17 @@ import {
|
||||||
type TechniqueListItem,
|
type TechniqueListItem,
|
||||||
} from "../api/public-client";
|
} from "../api/public-client";
|
||||||
import CreatorAvatar from "../components/CreatorAvatar";
|
import CreatorAvatar from "../components/CreatorAvatar";
|
||||||
|
import SortDropdown from "../components/SortDropdown";
|
||||||
import { catSlug } from "../utils/catSlug";
|
import { catSlug } from "../utils/catSlug";
|
||||||
import TagList from "../components/TagList";
|
import TagList from "../components/TagList";
|
||||||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||||
|
import { useSortPreference } from "../hooks/useSortPreference";
|
||||||
|
|
||||||
|
const CREATOR_SORT_OPTIONS = [
|
||||||
|
{ value: "newest", label: "Newest" },
|
||||||
|
{ value: "oldest", label: "Oldest" },
|
||||||
|
{ value: "alpha", label: "A–Z" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function CreatorDetail() {
|
export default function CreatorDetail() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
|
@ -25,6 +33,7 @@ export default function CreatorDetail() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [notFound, setNotFound] = useState(false);
|
const [notFound, setNotFound] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [sort, setSort] = useSortPreference("newest");
|
||||||
|
|
||||||
useDocumentTitle(creator ? `${creator.name} — Chrysopedia` : "Chrysopedia");
|
useDocumentTitle(creator ? `${creator.name} — Chrysopedia` : "Chrysopedia");
|
||||||
|
|
||||||
|
|
@ -40,7 +49,7 @@ export default function CreatorDetail() {
|
||||||
try {
|
try {
|
||||||
const [creatorData, techData] = await Promise.all([
|
const [creatorData, techData] = await Promise.all([
|
||||||
fetchCreator(slug),
|
fetchCreator(slug),
|
||||||
fetchTechniques({ creator_slug: slug, limit: 100 }),
|
fetchTechniques({ creator_slug: slug, limit: 100, sort }),
|
||||||
]);
|
]);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCreator(creatorData);
|
setCreator(creatorData);
|
||||||
|
|
@ -64,7 +73,7 @@ export default function CreatorDetail() {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [slug]);
|
}, [slug, sort]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loading">Loading creator…</div>;
|
return <div className="loading">Loading creator…</div>;
|
||||||
|
|
@ -137,9 +146,16 @@ export default function CreatorDetail() {
|
||||||
|
|
||||||
{/* Technique pages */}
|
{/* Technique pages */}
|
||||||
<section className="creator-techniques">
|
<section className="creator-techniques">
|
||||||
<h2 className="creator-techniques__title">
|
<div className="creator-techniques__header">
|
||||||
Techniques ({techniques.length})
|
<h2 className="creator-techniques__title">
|
||||||
</h2>
|
Techniques ({techniques.length})
|
||||||
|
</h2>
|
||||||
|
<SortDropdown
|
||||||
|
options={CREATOR_SORT_OPTIONS}
|
||||||
|
value={sort}
|
||||||
|
onChange={setSort}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{techniques.length === 0 ? (
|
{techniques.length === 0 ? (
|
||||||
<div className="empty-state">No techniques yet.</div>
|
<div className="empty-state">No techniques yet.</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,24 @@ import { Link, useSearchParams, useNavigate } from "react-router-dom";
|
||||||
import { searchApi, type SearchResultItem } from "../api/public-client";
|
import { searchApi, type SearchResultItem } from "../api/public-client";
|
||||||
import { catSlug } from "../utils/catSlug";
|
import { catSlug } from "../utils/catSlug";
|
||||||
import SearchAutocomplete from "../components/SearchAutocomplete";
|
import SearchAutocomplete from "../components/SearchAutocomplete";
|
||||||
|
import SortDropdown from "../components/SortDropdown";
|
||||||
import TagList from "../components/TagList";
|
import TagList from "../components/TagList";
|
||||||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||||
|
import { useSortPreference } from "../hooks/useSortPreference";
|
||||||
|
|
||||||
|
const SEARCH_SORT_OPTIONS = [
|
||||||
|
{ value: "relevance", label: "Relevance" },
|
||||||
|
{ value: "newest", label: "Newest" },
|
||||||
|
{ value: "oldest", label: "Oldest" },
|
||||||
|
{ value: "alpha", label: "A–Z" },
|
||||||
|
{ value: "creator", label: "Creator" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function SearchResults() {
|
export default function SearchResults() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const q = searchParams.get("q") ?? "";
|
const q = searchParams.get("q") ?? "";
|
||||||
|
const [sort, setSort] = useSortPreference("relevance");
|
||||||
|
|
||||||
useDocumentTitle(q ? `Search: ${q} — Chrysopedia` : "Search — Chrysopedia");
|
useDocumentTitle(q ? `Search: ${q} — Chrysopedia` : "Search — Chrysopedia");
|
||||||
|
|
||||||
|
|
@ -26,7 +37,7 @@ export default function SearchResults() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const doSearch = useCallback(async (query: string) => {
|
const doSearch = useCallback(async (query: string, sortBy: string) => {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setPartialMatches([]);
|
setPartialMatches([]);
|
||||||
|
|
@ -36,7 +47,7 @@ export default function SearchResults() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await searchApi(query.trim());
|
const res = await searchApi(query.trim(), undefined, undefined, sortBy);
|
||||||
setResults(res.items);
|
setResults(res.items);
|
||||||
setPartialMatches(res.partial_matches ?? []);
|
setPartialMatches(res.partial_matches ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -48,10 +59,10 @@ export default function SearchResults() {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Search when URL param changes
|
// Search when URL param or sort changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (q) void doSearch(q);
|
if (q) void doSearch(q, sort);
|
||||||
}, [q, doSearch]);
|
}, [q, sort, doSearch]);
|
||||||
|
|
||||||
// Group results by type
|
// Group results by type
|
||||||
const techniqueResults = results.filter((r) => r.type === "technique_page");
|
const techniqueResults = results.filter((r) => r.type === "technique_page");
|
||||||
|
|
@ -69,6 +80,15 @@ export default function SearchResults() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Sort control */}
|
||||||
|
{q && (
|
||||||
|
<SortDropdown
|
||||||
|
options={SEARCH_SORT_OPTIONS}
|
||||||
|
value={sort}
|
||||||
|
onChange={setSort}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
{loading && <div className="loading">Searching…</div>}
|
{loading && <div className="loading">Searching…</div>}
|
||||||
{error && <div className="loading error-text">Error: {error}</div>}
|
{error && <div className="loading error-text">Error: {error}</div>}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,17 @@ import {
|
||||||
type TechniqueListItem,
|
type TechniqueListItem,
|
||||||
} from "../api/public-client";
|
} from "../api/public-client";
|
||||||
import { catSlug } from "../utils/catSlug";
|
import { catSlug } from "../utils/catSlug";
|
||||||
|
import SortDropdown from "../components/SortDropdown";
|
||||||
import TagList from "../components/TagList";
|
import TagList from "../components/TagList";
|
||||||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||||
|
import { useSortPreference } from "../hooks/useSortPreference";
|
||||||
|
|
||||||
|
const SUBTOPIC_SORT_OPTIONS = [
|
||||||
|
{ value: "alpha", label: "A–Z" },
|
||||||
|
{ value: "newest", label: "Newest" },
|
||||||
|
{ value: "oldest", label: "Oldest" },
|
||||||
|
{ value: "creator", label: "Creator" },
|
||||||
|
];
|
||||||
|
|
||||||
/** Convert a URL slug to a display name: replace hyphens with spaces, title-case. */
|
/** Convert a URL slug to a display name: replace hyphens with spaces, title-case. */
|
||||||
function slugToDisplayName(slug: string): string {
|
function slugToDisplayName(slug: string): string {
|
||||||
|
|
@ -48,6 +57,7 @@ export default function SubTopicPage() {
|
||||||
const [techniques, setTechniques] = useState<TechniqueListItem[]>([]);
|
const [techniques, setTechniques] = useState<TechniqueListItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [sort, setSort] = useSortPreference("alpha");
|
||||||
|
|
||||||
const categoryDisplay = category ? slugToDisplayName(category) : "";
|
const categoryDisplay = category ? slugToDisplayName(category) : "";
|
||||||
const subtopicDisplay = subtopic ? slugToDisplayName(subtopic) : "";
|
const subtopicDisplay = subtopic ? slugToDisplayName(subtopic) : "";
|
||||||
|
|
@ -67,7 +77,7 @@ export default function SubTopicPage() {
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchSubTopicTechniques(category, subtopic, { limit: 100 });
|
const data = await fetchSubTopicTechniques(category, subtopic, { limit: 100, sort });
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setTechniques(data.items);
|
setTechniques(data.items);
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +95,7 @@ export default function SubTopicPage() {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [category, subtopic]);
|
}, [category, subtopic, sort]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loading">Loading techniques…</div>;
|
return <div className="loading">Loading techniques…</div>;
|
||||||
|
|
@ -123,6 +133,12 @@ export default function SubTopicPage() {
|
||||||
{techniques.length} technique{techniques.length !== 1 ? "s" : ""}
|
{techniques.length} technique{techniques.length !== 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<SortDropdown
|
||||||
|
options={SUBTOPIC_SORT_OPTIONS}
|
||||||
|
value={sort}
|
||||||
|
onChange={setSort}
|
||||||
|
/>
|
||||||
|
|
||||||
{techniques.length === 0 ? (
|
{techniques.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
No techniques found for this sub-topic.
|
No techniques found for this sub-topic.
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/TagList.tsx","./src/hooks/useDocumentTitle.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts"],"version":"5.6.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SortDropdown.tsx","./src/components/TagList.tsx","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts"],"version":"5.6.3"}
|
||||||
Loading…
Add table
Reference in a new issue