handzon-core 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/collections.ts +5 -0
- package/src/components/ai/ChatButton.tsx +1 -1
- package/src/components/ai/ChatPanel.tsx +9 -4
- package/src/components/auth/UserMenu.tsx +17 -8
- package/src/components/home/ActiveFilterChips.tsx +88 -0
- package/src/components/home/FilterBar.tsx +126 -77
- package/src/components/home/Pagination.tsx +1 -3
- package/src/components/home/ResumeRail.tsx +3 -1
- package/src/components/home/SortBar.tsx +65 -0
- package/src/components/home/TutorialCard.astro +57 -8
- package/src/components/mdx/Checkpoint.tsx +7 -2
- package/src/components/ui/Dropdown.tsx +91 -0
- package/src/components/ui/MultiSelect.tsx +205 -0
- package/src/index.ts +22 -27
- package/src/layouts/BaseLayout.astro +24 -1
- package/src/lib/progress/remote.ts +8 -0
- package/src/lib/progress/useProgress.ts +8 -0
- package/src/pages/Home.astro +65 -129
- package/src/pages/paths.ts +2 -1
- package/src/server/auth/config.ts +2 -1
- package/src/server/auth/schema.ts +1 -8
- package/src/server/auth.ts +1 -5
- package/src/server/db/schema.ts +1 -1
- package/src/server/handlers/progress.ts +50 -27
- package/src/server/handlers/tutorialStats.ts +6 -3
- package/styles/components/dropdown.css +144 -0
- package/styles/components/filterbar.css +128 -0
- package/styles/components/multiselect.css +206 -0
- package/styles/components.css +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handzon-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Core framework for Handzon — layouts, components, content + AI libs, and server runtime (handlers, DB, auth, migration runner) consumed by Handzon scaffolds.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -46,6 +46,8 @@
|
|
|
46
46
|
"@fontsource-variable/geist": "^5.2.9",
|
|
47
47
|
"@fontsource-variable/geist-mono": "^5.2.8",
|
|
48
48
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
49
|
+
"@radix-ui/react-popover": "^1.1.15",
|
|
50
|
+
"@radix-ui/react-select": "^2.1.6",
|
|
49
51
|
"auth-astro": "^4.2.0",
|
|
50
52
|
"cookie": "^1.0.2",
|
|
51
53
|
"diff": "^9.0.0",
|
package/src/collections.ts
CHANGED
|
@@ -121,6 +121,11 @@ export function tutorialsSchema({ image }: { image: () => import("astro/zod").Zo
|
|
|
121
121
|
estimatedDuration: z.string().optional(),
|
|
122
122
|
prerequisites: z.array(z.string()).default([]),
|
|
123
123
|
nextTutorial: z.string().optional(),
|
|
124
|
+
// TODO(handzon): `cover` and `icon` are accepted by the schema for
|
|
125
|
+
// forward compatibility, but no page currently renders them
|
|
126
|
+
// (Home cards, TutorialLanding, OG meta all ignore them). Wire them
|
|
127
|
+
// up in TutorialCard and BaseLayout's OG tags before promoting
|
|
128
|
+
// cover art in author-facing docs and skills.
|
|
124
129
|
cover: image().optional(),
|
|
125
130
|
icon: z.union([z.string(), image()]).optional(),
|
|
126
131
|
steps: z.array(z.string()).optional(),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Sparkles } from "lucide-react";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
-
import type { AiConfig } from "../../types/ai";
|
|
4
3
|
import type { AssistantContext } from "../../lib/ai/context";
|
|
4
|
+
import type { AiConfig } from "../../types/ai";
|
|
5
5
|
import ChatPanel from "./ChatPanel";
|
|
6
6
|
|
|
7
7
|
interface Props {
|
|
@@ -3,9 +3,9 @@ import { KeyRound, Send, Settings, Sparkles, Trash2, X } from "lucide-react";
|
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
import ReactMarkdown from "react-markdown";
|
|
5
5
|
import remarkGfm from "remark-gfm";
|
|
6
|
-
import type { AiConfig } from "../../types/ai";
|
|
7
6
|
import { type ChatMessage, clearLearnerKey, loadLearnerKey, streamChat } from "../../lib/ai/client";
|
|
8
7
|
import type { AssistantContext } from "../../lib/ai/context";
|
|
8
|
+
import type { AiConfig } from "../../types/ai";
|
|
9
9
|
import ByokSetup from "./ByokSetup";
|
|
10
10
|
|
|
11
11
|
interface Props {
|
|
@@ -45,6 +45,11 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
|
|
|
45
45
|
|
|
46
46
|
// Keep the latest message in view as chunks stream in (and on every
|
|
47
47
|
// send / clear). Without this, long responses scroll out of frame.
|
|
48
|
+
// The deps aren't read inside the effect — they're triggers, so the
|
|
49
|
+
// effect re-runs when a new chunk arrives or streaming flips. Biome's
|
|
50
|
+
// exhaustive-deps lint would have us remove them; that would break
|
|
51
|
+
// the autoscroll. Keep the suppression scoped to this single effect.
|
|
52
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: messages + streaming are intentional triggers
|
|
48
53
|
useEffect(() => {
|
|
49
54
|
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
|
|
50
55
|
}, [messages, streaming]);
|
|
@@ -162,8 +167,8 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
|
|
|
162
167
|
<KeyRound size={22} aria-hidden="true" />
|
|
163
168
|
<h3>API key required</h3>
|
|
164
169
|
<p>
|
|
165
|
-
{config.name} needs an API key to answer questions. Add one to get started —
|
|
166
|
-
|
|
170
|
+
{config.name} needs an API key to answer questions. Add one to get started — it's
|
|
171
|
+
stored in this browser only.
|
|
167
172
|
</p>
|
|
168
173
|
<button type="button" onClick={() => setByokOpen(true)}>
|
|
169
174
|
Set up key
|
|
@@ -197,7 +202,7 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
|
|
|
197
202
|
<div className="chat-msg chat-msg-assistant">
|
|
198
203
|
<span className="chat-role">{config.name}</span>
|
|
199
204
|
<div className="chat-content">
|
|
200
|
-
<span className="chat-thinking" aria-label="Thinking">
|
|
205
|
+
<span className="chat-thinking" role="status" aria-label="Thinking">
|
|
201
206
|
<span /> <span /> <span />
|
|
202
207
|
</span>
|
|
203
208
|
</div>
|
|
@@ -53,7 +53,7 @@ export default function UserMenu() {
|
|
|
53
53
|
}
|
|
54
54
|
const sess = (await sessRes.json()) as Session | null;
|
|
55
55
|
const csrf = (await csrfRes.json()) as { csrfToken?: string } | null;
|
|
56
|
-
setSession(sess
|
|
56
|
+
setSession(sess?.user ? sess : null);
|
|
57
57
|
setCsrfToken(csrf?.csrfToken ?? null);
|
|
58
58
|
} catch {
|
|
59
59
|
if (!cancelled) {
|
|
@@ -73,22 +73,31 @@ export default function UserMenu() {
|
|
|
73
73
|
const user = session?.user;
|
|
74
74
|
const callbackUrl = typeof window !== "undefined" ? window.location.href : "/";
|
|
75
75
|
|
|
76
|
+
// Compact label for the topbar: first word of `name`, falling back
|
|
77
|
+
// to the local part of `email`, falling back to a generic. Full name
|
|
78
|
+
// / email stays in the `alt` text and `title` for accessibility +
|
|
79
|
+
// long-form context.
|
|
80
|
+
const fullLabel = user?.name ?? user?.email ?? "Signed in";
|
|
81
|
+
const displayName = user
|
|
82
|
+
? ((user.name ? user.name.trim().split(/\s+/)[0] : null) ??
|
|
83
|
+
(user.email ? user.email.split("@")[0] : null) ??
|
|
84
|
+
"Signed in")
|
|
85
|
+
: "";
|
|
86
|
+
|
|
76
87
|
return (
|
|
77
88
|
<div className="user-menu">
|
|
78
89
|
{user ? (
|
|
79
90
|
<>
|
|
80
91
|
{user.image ? (
|
|
81
|
-
<img
|
|
82
|
-
className="um-avatar"
|
|
83
|
-
src={user.image}
|
|
84
|
-
alt={user.name ?? user.email ?? "Signed in"}
|
|
85
|
-
/>
|
|
92
|
+
<img className="um-avatar" src={user.image} alt={fullLabel} />
|
|
86
93
|
) : (
|
|
87
94
|
<span className="um-avatar um-avatar-fallback" aria-hidden="true">
|
|
88
|
-
{
|
|
95
|
+
{fullLabel.trim().charAt(0).toUpperCase()}
|
|
89
96
|
</span>
|
|
90
97
|
)}
|
|
91
|
-
<span className="um-name"
|
|
98
|
+
<span className="um-name" title={fullLabel}>
|
|
99
|
+
{displayName}
|
|
100
|
+
</span>
|
|
92
101
|
<form method="post" action="/api/auth/signout">
|
|
93
102
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
|
94
103
|
<input type="hidden" name="callbackUrl" value={callbackUrl} />
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import type { KeyboardEvent } from "react";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
q: string;
|
|
6
|
+
levels: Set<string>;
|
|
7
|
+
tags: Set<string>;
|
|
8
|
+
onClearQ: () => void;
|
|
9
|
+
onRemoveLevel: (level: string) => void;
|
|
10
|
+
onRemoveTag: (tag: string) => void;
|
|
11
|
+
onClearAll: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Active-filter chip row. Pure presentation — the parent FilterBar
|
|
16
|
+
* owns state mutations and URL writes. Shown only when the parent
|
|
17
|
+
* has determined at least one filter is active.
|
|
18
|
+
*
|
|
19
|
+
* Each chip is a button. Clicking removes the facet value.
|
|
20
|
+
* Backspace/Delete on a focused chip removes it via keyboard.
|
|
21
|
+
*
|
|
22
|
+
* The trailing "Clear all" sits at the end and resets everything
|
|
23
|
+
* (search + levels + tags). This is the only "clear" affordance now;
|
|
24
|
+
* the in-toolbar Clear button was removed.
|
|
25
|
+
*/
|
|
26
|
+
export default function ActiveFilterChips({
|
|
27
|
+
q,
|
|
28
|
+
levels,
|
|
29
|
+
tags,
|
|
30
|
+
onClearQ,
|
|
31
|
+
onRemoveLevel,
|
|
32
|
+
onRemoveTag,
|
|
33
|
+
onClearAll,
|
|
34
|
+
}: Props) {
|
|
35
|
+
function chipKey(e: KeyboardEvent<HTMLButtonElement>, remove: () => void) {
|
|
36
|
+
if (e.key === "Backspace" || e.key === "Delete") {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
remove();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
// biome-ignore lint/a11y/useSemanticElements: <fieldset> requires <legend> and carries form-control semantics; this row groups filter-removal buttons.
|
|
44
|
+
<div className="active-filters" role="group" aria-label="Active filters">
|
|
45
|
+
{q && (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
className="active-filter-chip"
|
|
49
|
+
aria-label={`Remove search: ${q}`}
|
|
50
|
+
onClick={onClearQ}
|
|
51
|
+
onKeyDown={(e) => chipKey(e, onClearQ)}
|
|
52
|
+
>
|
|
53
|
+
<span className="afc-label">search: {q}</span>
|
|
54
|
+
<X size={12} aria-hidden="true" />
|
|
55
|
+
</button>
|
|
56
|
+
)}
|
|
57
|
+
{[...levels].sort().map((level) => (
|
|
58
|
+
<button
|
|
59
|
+
key={`l:${level}`}
|
|
60
|
+
type="button"
|
|
61
|
+
className="active-filter-chip"
|
|
62
|
+
aria-label={`Remove level: ${level}`}
|
|
63
|
+
onClick={() => onRemoveLevel(level)}
|
|
64
|
+
onKeyDown={(e) => chipKey(e, () => onRemoveLevel(level))}
|
|
65
|
+
>
|
|
66
|
+
<span className="afc-label">{level}</span>
|
|
67
|
+
<X size={12} aria-hidden="true" />
|
|
68
|
+
</button>
|
|
69
|
+
))}
|
|
70
|
+
{[...tags].sort().map((tag) => (
|
|
71
|
+
<button
|
|
72
|
+
key={`t:${tag}`}
|
|
73
|
+
type="button"
|
|
74
|
+
className="active-filter-chip"
|
|
75
|
+
aria-label={`Remove topic: ${tag}`}
|
|
76
|
+
onClick={() => onRemoveTag(tag)}
|
|
77
|
+
onKeyDown={(e) => chipKey(e, () => onRemoveTag(tag))}
|
|
78
|
+
>
|
|
79
|
+
<span className="afc-label">#{tag}</span>
|
|
80
|
+
<X size={12} aria-hidden="true" />
|
|
81
|
+
</button>
|
|
82
|
+
))}
|
|
83
|
+
<button type="button" className="active-filter-clear" onClick={onClearAll}>
|
|
84
|
+
Clear all
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -1,27 +1,48 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { GraduationCap, Hash, Search } from "lucide-react";
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
|
+
import MultiSelect, { type MultiSelectOption } from "../ui/MultiSelect.tsx";
|
|
4
|
+
import ActiveFilterChips from "./ActiveFilterChips.tsx";
|
|
5
|
+
import SortBar from "./SortBar.tsx";
|
|
3
6
|
|
|
4
7
|
interface Props {
|
|
5
8
|
difficulties: string[];
|
|
6
9
|
tags: string[];
|
|
10
|
+
/** Hit counts per difficulty value, server-computed. */
|
|
11
|
+
difficultyCounts: Record<string, number>;
|
|
12
|
+
/** Hit counts per tag, server-computed. */
|
|
13
|
+
tagCounts: Record<string, number>;
|
|
7
14
|
}
|
|
8
15
|
|
|
9
16
|
interface FilterState {
|
|
10
17
|
q: string;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
levels: Set<string>;
|
|
19
|
+
tags: Set<string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseCsv(value: string | null): Set<string> {
|
|
23
|
+
if (!value) return new Set();
|
|
24
|
+
return new Set(
|
|
25
|
+
value
|
|
26
|
+
.split(",")
|
|
27
|
+
.map((s) => s.trim())
|
|
28
|
+
.filter(Boolean),
|
|
29
|
+
);
|
|
15
30
|
}
|
|
16
31
|
|
|
17
32
|
function readUrlState(): FilterState {
|
|
18
|
-
if (typeof window === "undefined")
|
|
33
|
+
if (typeof window === "undefined") {
|
|
34
|
+
return { q: "", levels: new Set(), tags: new Set() };
|
|
35
|
+
}
|
|
19
36
|
const url = new URL(window.location.href);
|
|
37
|
+
const levels = url.searchParams.get("level")
|
|
38
|
+
? parseCsv(url.searchParams.get("level"))
|
|
39
|
+
: // Legacy single-value shape; honor on read so shared links keep
|
|
40
|
+
// working. The next interaction rewrites to ?level=.
|
|
41
|
+
parseCsv(url.searchParams.get("difficulty"));
|
|
20
42
|
return {
|
|
21
43
|
q: url.searchParams.get("q") ?? "",
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
sort: url.searchParams.get("sort") ?? "",
|
|
44
|
+
levels,
|
|
45
|
+
tags: parseCsv(url.searchParams.get("tag")),
|
|
25
46
|
};
|
|
26
47
|
}
|
|
27
48
|
|
|
@@ -29,12 +50,13 @@ function writeUrlState(state: FilterState) {
|
|
|
29
50
|
const url = new URL(window.location.href);
|
|
30
51
|
if (state.q) url.searchParams.set("q", state.q);
|
|
31
52
|
else url.searchParams.delete("q");
|
|
32
|
-
if (state.
|
|
33
|
-
else url.searchParams.delete("
|
|
34
|
-
if (state.
|
|
53
|
+
if (state.levels.size > 0) url.searchParams.set("level", [...state.levels].join(","));
|
|
54
|
+
else url.searchParams.delete("level");
|
|
55
|
+
if (state.tags.size > 0) url.searchParams.set("tag", [...state.tags].join(","));
|
|
35
56
|
else url.searchParams.delete("tag");
|
|
36
|
-
|
|
37
|
-
|
|
57
|
+
// Always strip the legacy key so an upgraded URL doesn't carry both
|
|
58
|
+
// shapes. First user interaction "migrates" the link.
|
|
59
|
+
url.searchParams.delete("difficulty");
|
|
38
60
|
window.history.replaceState({}, "", url.toString());
|
|
39
61
|
}
|
|
40
62
|
|
|
@@ -44,9 +66,10 @@ function applyFilters(state: FilterState) {
|
|
|
44
66
|
let visible = 0;
|
|
45
67
|
cards.forEach((card) => {
|
|
46
68
|
const matchesQ = !q || card.dataset.search!.includes(q);
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
69
|
+
const matchesLevel = state.levels.size === 0 || state.levels.has(card.dataset.difficulty ?? "");
|
|
70
|
+
const cardTags = (card.dataset.tags ?? "").split(",");
|
|
71
|
+
const matchesTag = state.tags.size === 0 || cardTags.some((t) => state.tags.has(t));
|
|
72
|
+
const show = matchesQ && matchesLevel && matchesTag;
|
|
50
73
|
if (show) {
|
|
51
74
|
card.removeAttribute("data-filter-hidden");
|
|
52
75
|
visible += 1;
|
|
@@ -54,98 +77,124 @@ function applyFilters(state: FilterState) {
|
|
|
54
77
|
card.setAttribute("data-filter-hidden", "");
|
|
55
78
|
}
|
|
56
79
|
});
|
|
80
|
+
// Empty state element controls its own visibility; we just set it.
|
|
81
|
+
// The Home.astro inline empty-state is shown when visible === 0.
|
|
57
82
|
const empty = document.querySelector<HTMLElement>("[data-empty-state]");
|
|
58
83
|
if (empty) empty.style.display = visible === 0 ? "" : "none";
|
|
59
|
-
//
|
|
60
|
-
//
|
|
84
|
+
// Results status line. Read total from rendered card count (server
|
|
85
|
+
// SSR'd them; localStorage / pagination don't change cards.length).
|
|
86
|
+
const status = document.querySelector<HTMLElement>("[data-results-status]");
|
|
87
|
+
if (status) {
|
|
88
|
+
const total = cards.length;
|
|
89
|
+
if (total === 0) {
|
|
90
|
+
status.textContent = "";
|
|
91
|
+
} else if (visible === total) {
|
|
92
|
+
status.textContent = `Showing all ${total} tutorial${total === 1 ? "" : "s"}`;
|
|
93
|
+
} else {
|
|
94
|
+
status.textContent = `Showing ${visible} of ${total} tutorial${total === 1 ? "" : "s"}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Fire once per state transition so Pagination resets to page 1.
|
|
61
98
|
window.dispatchEvent(new CustomEvent("hz:filter-changed"));
|
|
62
99
|
}
|
|
63
100
|
|
|
64
|
-
export default function FilterBar({ difficulties, tags }: Props) {
|
|
101
|
+
export default function FilterBar({ difficulties, tags, difficultyCounts, tagCounts }: Props) {
|
|
65
102
|
const [state, setState] = useState<FilterState>(readUrlState);
|
|
66
103
|
|
|
67
104
|
useEffect(() => {
|
|
68
105
|
applyFilters(state);
|
|
69
106
|
writeUrlState(state);
|
|
70
|
-
// Sort lives in a separate inline script on Home.astro because it
|
|
71
|
-
// doesn't depend on React state — it just reads data-popularity.
|
|
72
|
-
// Fire an event so it can re-run.
|
|
73
|
-
window.dispatchEvent(
|
|
74
|
-
new CustomEvent("hz:sort-changed", { detail: { sort: state.sort } }),
|
|
75
|
-
);
|
|
76
107
|
}, [state]);
|
|
77
108
|
|
|
78
|
-
function
|
|
79
|
-
setState((prev) => ({ ...prev,
|
|
109
|
+
function setQ(q: string) {
|
|
110
|
+
setState((prev) => ({ ...prev, q }));
|
|
80
111
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
112
|
+
function setLevels(levels: Set<string>) {
|
|
113
|
+
setState((prev) => ({ ...prev, levels }));
|
|
114
|
+
}
|
|
115
|
+
function setTags(next: Set<string>) {
|
|
116
|
+
setState((prev) => ({ ...prev, tags: next }));
|
|
84
117
|
}
|
|
118
|
+
function removeLevel(level: string) {
|
|
119
|
+
setState((prev) => {
|
|
120
|
+
const next = new Set(prev.levels);
|
|
121
|
+
next.delete(level);
|
|
122
|
+
return { ...prev, levels: next };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function removeTag(tag: string) {
|
|
126
|
+
setState((prev) => {
|
|
127
|
+
const next = new Set(prev.tags);
|
|
128
|
+
next.delete(tag);
|
|
129
|
+
return { ...prev, tags: next };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
function clearAll() {
|
|
133
|
+
setState({ q: "", levels: new Set(), tags: new Set() });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const levelOpts: MultiSelectOption[] = difficulties.map((d) => ({
|
|
137
|
+
value: d,
|
|
138
|
+
label: d,
|
|
139
|
+
count: difficultyCounts[d] ?? 0,
|
|
140
|
+
}));
|
|
141
|
+
const tagOpts: MultiSelectOption[] = tags.map((t) => ({
|
|
142
|
+
value: t,
|
|
143
|
+
label: `#${t}`,
|
|
144
|
+
count: tagCounts[t] ?? 0,
|
|
145
|
+
}));
|
|
85
146
|
|
|
86
|
-
const
|
|
147
|
+
const hasActive = state.q.length > 0 || state.levels.size > 0 || state.tags.size > 0;
|
|
87
148
|
|
|
88
149
|
return (
|
|
89
150
|
<div className="filterbar">
|
|
90
|
-
|
|
91
|
-
<div className="fb-row fb-row-primary">
|
|
151
|
+
<div className="fb-toolbar">
|
|
92
152
|
<label className="search">
|
|
93
153
|
<Search size={16} aria-hidden="true" />
|
|
94
154
|
<input
|
|
95
155
|
type="search"
|
|
96
156
|
placeholder="Search tutorials…"
|
|
97
157
|
value={state.q}
|
|
98
|
-
onChange={(e) =>
|
|
158
|
+
onChange={(e) => setQ(e.target.value)}
|
|
99
159
|
aria-label="Search tutorials"
|
|
100
160
|
/>
|
|
101
161
|
</label>
|
|
102
162
|
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
{d}
|
|
112
|
-
</button>
|
|
113
|
-
))}
|
|
114
|
-
</div>
|
|
163
|
+
<MultiSelect
|
|
164
|
+
id="hz-level"
|
|
165
|
+
label="Level"
|
|
166
|
+
values={state.levels}
|
|
167
|
+
onChange={setLevels}
|
|
168
|
+
options={levelOpts}
|
|
169
|
+
triggerIcon={<GraduationCap size={14} aria-hidden="true" />}
|
|
170
|
+
/>
|
|
115
171
|
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
</button>
|
|
126
|
-
</div>
|
|
172
|
+
<MultiSelect
|
|
173
|
+
id="hz-topics"
|
|
174
|
+
label="Topics"
|
|
175
|
+
values={state.tags}
|
|
176
|
+
onChange={setTags}
|
|
177
|
+
options={tagOpts}
|
|
178
|
+
searchable
|
|
179
|
+
triggerIcon={<Hash size={14} aria-hidden="true" />}
|
|
180
|
+
/>
|
|
127
181
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
)}
|
|
182
|
+
<span className="fb-divider" aria-hidden="true" />
|
|
183
|
+
<div className="fb-sort-slot">
|
|
184
|
+
<SortBar />
|
|
185
|
+
</div>
|
|
133
186
|
</div>
|
|
134
187
|
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
{
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
#{t}
|
|
146
|
-
</button>
|
|
147
|
-
))}
|
|
148
|
-
</div>
|
|
188
|
+
{hasActive && (
|
|
189
|
+
<ActiveFilterChips
|
|
190
|
+
q={state.q}
|
|
191
|
+
levels={state.levels}
|
|
192
|
+
tags={state.tags}
|
|
193
|
+
onClearQ={() => setQ("")}
|
|
194
|
+
onRemoveLevel={removeLevel}
|
|
195
|
+
onRemoveTag={removeTag}
|
|
196
|
+
onClearAll={clearAll}
|
|
197
|
+
/>
|
|
149
198
|
)}
|
|
150
199
|
</div>
|
|
151
200
|
);
|
|
@@ -19,9 +19,7 @@ interface Props {
|
|
|
19
19
|
*/
|
|
20
20
|
function applyPagination(page: number, pageSize: number): number {
|
|
21
21
|
const visibleByFilter = Array.from(
|
|
22
|
-
document.querySelectorAll<HTMLElement>(
|
|
23
|
-
"[data-search]:not([data-filter-hidden])",
|
|
24
|
-
),
|
|
22
|
+
document.querySelectorAll<HTMLElement>("[data-search]:not([data-filter-hidden])"),
|
|
25
23
|
);
|
|
26
24
|
const start = (page - 1) * pageSize;
|
|
27
25
|
const end = start + pageSize;
|
|
@@ -44,7 +44,9 @@ export default function ResumeRail({ tutorials }: Props) {
|
|
|
44
44
|
<span className="rr-prefix">Continue</span>
|
|
45
45
|
<span className="rr-title">{mostRecent.title}</span>
|
|
46
46
|
<span className="rr-step">/ {mostRecent.step}</span>
|
|
47
|
-
<span className="rr-arrow" aria-hidden="true"
|
|
47
|
+
<span className="rr-arrow" aria-hidden="true">
|
|
48
|
+
→
|
|
49
|
+
</span>
|
|
48
50
|
</a>
|
|
49
51
|
);
|
|
50
52
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ArrowUpDown } from "lucide-react";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import Dropdown, { type DropdownOption } from "../ui/Dropdown.tsx";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sort dropdown for the homepage tutorial grid. Lives above the grid
|
|
7
|
+
* (not inside the filterbar) so the UI affordance signals "order"
|
|
8
|
+
* rather than "filter".
|
|
9
|
+
*
|
|
10
|
+
* URL-driven via `?sort=`. Internally we use the sentinel "default"
|
|
11
|
+
* because Radix Select forbids "" as an item value; the URL still
|
|
12
|
+
* uses an absent param to mean "curated default order".
|
|
13
|
+
* Emits `hz:sort-changed` so the inline script on Home.astro that
|
|
14
|
+
* rewrites CSS `order` on each card re-runs without React coupling.
|
|
15
|
+
*/
|
|
16
|
+
type SortValue = "default" | "popular";
|
|
17
|
+
|
|
18
|
+
const OPTIONS: DropdownOption<SortValue>[] = [
|
|
19
|
+
{ value: "default", label: "Default" },
|
|
20
|
+
{ value: "popular", label: "Most popular" },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function readSortFromUrl(): SortValue {
|
|
24
|
+
if (typeof window === "undefined") return "default";
|
|
25
|
+
const v = new URL(window.location.href).searchParams.get("sort") ?? "";
|
|
26
|
+
return v === "popular" ? "popular" : "default";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeSortToUrl(value: SortValue) {
|
|
30
|
+
const url = new URL(window.location.href);
|
|
31
|
+
if (value === "popular") url.searchParams.set("sort", "popular");
|
|
32
|
+
else url.searchParams.delete("sort");
|
|
33
|
+
window.history.replaceState({}, "", url.toString());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function SortBar() {
|
|
37
|
+
const [sort, setSort] = useState<SortValue>("default");
|
|
38
|
+
|
|
39
|
+
// Read once on mount (client-only) so SSR doesn't try to touch
|
|
40
|
+
// `window`. After the initial sync, the dropdown owns the URL.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
setSort(readSortFromUrl());
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
writeSortToUrl(sort);
|
|
47
|
+
// Translate the internal sentinel back to "" on the wire so the
|
|
48
|
+
// existing Home.astro inline reorder script (which checks for the
|
|
49
|
+
// "popular" string) keeps working without changes.
|
|
50
|
+
const wire = sort === "popular" ? "popular" : "";
|
|
51
|
+
window.dispatchEvent(new CustomEvent("hz:sort-changed", { detail: { sort: wire } }));
|
|
52
|
+
}, [sort]);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Dropdown<SortValue>
|
|
56
|
+
id="hz-sort"
|
|
57
|
+
value={sort}
|
|
58
|
+
onChange={setSort}
|
|
59
|
+
options={OPTIONS}
|
|
60
|
+
label="Sort"
|
|
61
|
+
triggerIcon={<ArrowUpDown size={14} aria-hidden="true" />}
|
|
62
|
+
ariaLabel="Sort tutorials"
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
}
|