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.
+- [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 `` 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 (
+
+
+ Sort by
+
+ onChange(e.target.value)}
+ >
+ {options.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ );
+}
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