From baef500de6b98b2d732da1398c6c48faea6490b2 Mon Sep 17 00:00:00 2001 From: jlightner Date: Wed, 1 Apr 2026 06:33:49 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Created=20SortDropdown=20component=20an?= =?UTF-8?q?d=20useSortPreference=20hook,=20integr=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "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 --- .gsd/milestones/M012/slices/S02/S02-PLAN.md | 2 +- .../M012/slices/S02/tasks/T01-VERIFY.json | 9 ++ .../M012/slices/S02/tasks/T02-SUMMARY.md | 92 +++++++++++++++++++ frontend/src/App.css | 62 +++++++++++++ frontend/src/api/public-client.ts | 5 +- frontend/src/components/SortDropdown.tsx | 42 +++++++++ frontend/src/hooks/useSortPreference.ts | 30 ++++++ frontend/src/pages/CreatorDetail.tsx | 26 +++++- frontend/src/pages/SearchResults.tsx | 30 +++++- frontend/src/pages/SubTopicPage.tsx | 20 +++- frontend/tsconfig.app.tsbuildinfo | 2 +- 11 files changed, 305 insertions(+), 15 deletions(-) create mode 100644 .gsd/milestones/M012/slices/S02/tasks/T01-VERIFY.json create mode 100644 .gsd/milestones/M012/slices/S02/tasks/T02-SUMMARY.md create mode 100644 frontend/src/components/SortDropdown.tsx create mode 100644 frontend/src/hooks/useSortPreference.ts diff --git a/.gsd/milestones/M012/slices/S02/S02-PLAN.md b/.gsd/milestones/M012/slices/S02/S02-PLAN.md index 091b323..ce7f806 100644 --- a/.gsd/milestones/M012/slices/S02/S02-PLAN.md +++ b/.gsd/milestones/M012/slices/S02/S02-PLAN.md @@ -11,7 +11,7 @@ - Estimate: 25min - 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. -- [ ] **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 `` 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. 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(). diff --git a/.gsd/milestones/M012/slices/S02/tasks/T01-VERIFY.json b/.gsd/milestones/M012/slices/S02/tasks/T01-VERIFY.json new file mode 100644 index 0000000..d9d69cd --- /dev/null +++ b/.gsd/milestones/M012/slices/S02/tasks/T01-VERIFY.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "taskId": "T01", + "unitId": "M012/S02/T01", + "timestamp": 1775024876034, + "passed": true, + "discoverySource": "none", + "checks": [] +} diff --git a/.gsd/milestones/M012/slices/S02/tasks/T02-SUMMARY.md b/.gsd/milestones/M012/slices/S02/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..64b1547 --- /dev/null +++ b/.gsd/milestones/M012/slices/S02/tasks/T02-SUMMARY.md @@ -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. diff --git a/frontend/src/App.css b/frontend/src/App.css index 7d890c4..246b01c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -4716,6 +4716,68 @@ a.app-footer__about:hover, /* ── 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 { from { opacity: 0; diff --git a/frontend/src/api/public-client.ts b/frontend/src/api/public-client.ts index 7addf57..a6a536b 100644 --- a/frontend/src/api/public-client.ts +++ b/frontend/src/api/public-client.ts @@ -221,10 +221,12 @@ export async function searchApi( q: string, scope?: string, limit?: number, + sort?: string, ): Promise { const qs = new URLSearchParams({ q }); if (scope) qs.set("scope", scope); if (limit !== undefined) qs.set("limit", String(limit)); + if (sort) qs.set("sort", sort); return request(`${BASE}/search?${qs.toString()}`); } @@ -289,11 +291,12 @@ export async function fetchTopics(): Promise { export async function fetchSubTopicTechniques( categorySlug: string, subtopicSlug: string, - params: { limit?: number; offset?: number } = {}, + params: { limit?: number; offset?: number; sort?: string } = {}, ): Promise { const qs = new URLSearchParams(); if (params.limit !== undefined) qs.set("limit", String(params.limit)); if (params.offset !== undefined) qs.set("offset", String(params.offset)); + if (params.sort) qs.set("sort", params.sort); const query = qs.toString(); return request( `${BASE}/topics/${encodeURIComponent(categorySlug)}/${encodeURIComponent(subtopicSlug)}${query ? `?${query}` : ""}`, diff --git a/frontend/src/components/SortDropdown.tsx b/frontend/src/components/SortDropdown.tsx new file mode 100644 index 0000000..3eaea72 --- /dev/null +++ b/frontend/src/components/SortDropdown.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/frontend/src/hooks/useSortPreference.ts b/frontend/src/hooks/useSortPreference.ts new file mode 100644 index 0000000..4e35653 --- /dev/null +++ b/frontend/src/hooks/useSortPreference.ts @@ -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(() => { + 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]; +} diff --git a/frontend/src/pages/CreatorDetail.tsx b/frontend/src/pages/CreatorDetail.tsx index e18c338..6d0091d 100644 --- a/frontend/src/pages/CreatorDetail.tsx +++ b/frontend/src/pages/CreatorDetail.tsx @@ -14,9 +14,17 @@ import { type TechniqueListItem, } from "../api/public-client"; import CreatorAvatar from "../components/CreatorAvatar"; +import SortDropdown from "../components/SortDropdown"; import { catSlug } from "../utils/catSlug"; import TagList from "../components/TagList"; 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() { const { slug } = useParams<{ slug: string }>(); @@ -25,6 +33,7 @@ export default function CreatorDetail() { const [loading, setLoading] = useState(true); const [notFound, setNotFound] = useState(false); const [error, setError] = useState(null); + const [sort, setSort] = useSortPreference("newest"); useDocumentTitle(creator ? `${creator.name} — Chrysopedia` : "Chrysopedia"); @@ -40,7 +49,7 @@ export default function CreatorDetail() { try { const [creatorData, techData] = await Promise.all([ fetchCreator(slug), - fetchTechniques({ creator_slug: slug, limit: 100 }), + fetchTechniques({ creator_slug: slug, limit: 100, sort }), ]); if (!cancelled) { setCreator(creatorData); @@ -64,7 +73,7 @@ export default function CreatorDetail() { return () => { cancelled = true; }; - }, [slug]); + }, [slug, sort]); if (loading) { return
Loading creator…
; @@ -137,9 +146,16 @@ export default function CreatorDetail() { {/* Technique pages */}
-

- Techniques ({techniques.length}) -

+
+

+ Techniques ({techniques.length}) +

+ +
{techniques.length === 0 ? (
No techniques yet.
) : ( diff --git a/frontend/src/pages/SearchResults.tsx b/frontend/src/pages/SearchResults.tsx index f769ac6..00cb2b1 100644 --- a/frontend/src/pages/SearchResults.tsx +++ b/frontend/src/pages/SearchResults.tsx @@ -11,13 +11,24 @@ import { Link, useSearchParams, useNavigate } from "react-router-dom"; import { searchApi, type SearchResultItem } from "../api/public-client"; import { catSlug } from "../utils/catSlug"; import SearchAutocomplete from "../components/SearchAutocomplete"; +import SortDropdown from "../components/SortDropdown"; import TagList from "../components/TagList"; 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() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); const q = searchParams.get("q") ?? ""; + const [sort, setSort] = useSortPreference("relevance"); useDocumentTitle(q ? `Search: ${q} — Chrysopedia` : "Search — Chrysopedia"); @@ -26,7 +37,7 @@ export default function SearchResults() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const doSearch = useCallback(async (query: string) => { + const doSearch = useCallback(async (query: string, sortBy: string) => { if (!query.trim()) { setResults([]); setPartialMatches([]); @@ -36,7 +47,7 @@ export default function SearchResults() { setLoading(true); setError(null); try { - const res = await searchApi(query.trim()); + const res = await searchApi(query.trim(), undefined, undefined, sortBy); setResults(res.items); setPartialMatches(res.partial_matches ?? []); } catch (err) { @@ -48,10 +59,10 @@ export default function SearchResults() { } }, []); - // Search when URL param changes + // Search when URL param or sort changes useEffect(() => { - if (q) void doSearch(q); - }, [q, doSearch]); + if (q) void doSearch(q, sort); + }, [q, sort, doSearch]); // Group results by type const techniqueResults = results.filter((r) => r.type === "technique_page"); @@ -69,6 +80,15 @@ export default function SearchResults() { } /> + {/* Sort control */} + {q && ( + + )} + {/* Status */} {loading &&
Searching…
} {error &&
Error: {error}
} diff --git a/frontend/src/pages/SubTopicPage.tsx b/frontend/src/pages/SubTopicPage.tsx index c0b4fd9..63e3c4d 100644 --- a/frontend/src/pages/SubTopicPage.tsx +++ b/frontend/src/pages/SubTopicPage.tsx @@ -12,8 +12,17 @@ import { type TechniqueListItem, } from "../api/public-client"; import { catSlug } from "../utils/catSlug"; +import SortDropdown from "../components/SortDropdown"; import TagList from "../components/TagList"; 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. */ function slugToDisplayName(slug: string): string { @@ -48,6 +57,7 @@ export default function SubTopicPage() { const [techniques, setTechniques] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [sort, setSort] = useSortPreference("alpha"); const categoryDisplay = category ? slugToDisplayName(category) : ""; const subtopicDisplay = subtopic ? slugToDisplayName(subtopic) : ""; @@ -67,7 +77,7 @@ export default function SubTopicPage() { void (async () => { try { - const data = await fetchSubTopicTechniques(category, subtopic, { limit: 100 }); + const data = await fetchSubTopicTechniques(category, subtopic, { limit: 100, sort }); if (!cancelled) { setTechniques(data.items); } @@ -85,7 +95,7 @@ export default function SubTopicPage() { return () => { cancelled = true; }; - }, [category, subtopic]); + }, [category, subtopic, sort]); if (loading) { return
Loading techniques…
; @@ -123,6 +133,12 @@ export default function SubTopicPage() { {techniques.length} technique{techniques.length !== 1 ? "s" : ""}

+ + {techniques.length === 0 ? (
No techniques found for this sub-topic. diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index 7844bb1..5872313 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file