From 630d3fa47733fd944d24beba3fae7c588f77c92e 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 --- 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 +- 8 files changed, 203 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/SortDropdown.tsx create mode 100644 frontend/src/hooks/useSortPreference.ts 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