handzon-core 0.6.0 → 0.6.2
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/components/auth/UserMenu.tsx +14 -7
- package/src/components/home/ActiveFilterChips.tsx +91 -0
- package/src/components/home/FilterBar.tsx +128 -77
- package/src/components/home/SortBar.tsx +65 -0
- package/src/components/home/TutorialCard.astro +57 -8
- package/src/components/ui/Dropdown.tsx +99 -0
- package/src/components/ui/MultiSelect.tsx +219 -0
- package/src/layouts/BaseLayout.astro +24 -1
- package/src/pages/Home.astro +65 -129
- package/styles/base.css +10 -0
- 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.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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",
|
|
@@ -73,22 +73,29 @@ 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}>{displayName}</span>
|
|
92
99
|
<form method="post" action="/api/auth/signout">
|
|
93
100
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
|
94
101
|
<input type="hidden" name="callbackUrl" value={callbackUrl} />
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
<div className="active-filters" role="group" aria-label="Active filters">
|
|
44
|
+
{q && (
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
className="active-filter-chip"
|
|
48
|
+
aria-label={`Remove search: ${q}`}
|
|
49
|
+
onClick={onClearQ}
|
|
50
|
+
onKeyDown={(e) => chipKey(e, onClearQ)}
|
|
51
|
+
>
|
|
52
|
+
<span className="afc-label">search: {q}</span>
|
|
53
|
+
<X size={12} aria-hidden="true" />
|
|
54
|
+
</button>
|
|
55
|
+
)}
|
|
56
|
+
{[...levels].sort().map((level) => (
|
|
57
|
+
<button
|
|
58
|
+
key={`l:${level}`}
|
|
59
|
+
type="button"
|
|
60
|
+
className="active-filter-chip"
|
|
61
|
+
aria-label={`Remove level: ${level}`}
|
|
62
|
+
onClick={() => onRemoveLevel(level)}
|
|
63
|
+
onKeyDown={(e) => chipKey(e, () => onRemoveLevel(level))}
|
|
64
|
+
>
|
|
65
|
+
<span className="afc-label">{level}</span>
|
|
66
|
+
<X size={12} aria-hidden="true" />
|
|
67
|
+
</button>
|
|
68
|
+
))}
|
|
69
|
+
{[...tags].sort().map((tag) => (
|
|
70
|
+
<button
|
|
71
|
+
key={`t:${tag}`}
|
|
72
|
+
type="button"
|
|
73
|
+
className="active-filter-chip"
|
|
74
|
+
aria-label={`Remove topic: ${tag}`}
|
|
75
|
+
onClick={() => onRemoveTag(tag)}
|
|
76
|
+
onKeyDown={(e) => chipKey(e, () => onRemoveTag(tag))}
|
|
77
|
+
>
|
|
78
|
+
<span className="afc-label">#{tag}</span>
|
|
79
|
+
<X size={12} aria-hidden="true" />
|
|
80
|
+
</button>
|
|
81
|
+
))}
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
className="active-filter-clear"
|
|
85
|
+
onClick={onClearAll}
|
|
86
|
+
>
|
|
87
|
+
Clear all
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -1,27 +1,43 @@
|
|
|
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(value.split(",").map((s) => s.trim()).filter(Boolean));
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
function readUrlState(): FilterState {
|
|
18
|
-
if (typeof window === "undefined")
|
|
28
|
+
if (typeof window === "undefined") {
|
|
29
|
+
return { q: "", levels: new Set(), tags: new Set() };
|
|
30
|
+
}
|
|
19
31
|
const url = new URL(window.location.href);
|
|
32
|
+
const levels = url.searchParams.get("level")
|
|
33
|
+
? parseCsv(url.searchParams.get("level"))
|
|
34
|
+
: // Legacy single-value shape; honor on read so shared links keep
|
|
35
|
+
// working. The next interaction rewrites to ?level=.
|
|
36
|
+
parseCsv(url.searchParams.get("difficulty"));
|
|
20
37
|
return {
|
|
21
38
|
q: url.searchParams.get("q") ?? "",
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
sort: url.searchParams.get("sort") ?? "",
|
|
39
|
+
levels,
|
|
40
|
+
tags: parseCsv(url.searchParams.get("tag")),
|
|
25
41
|
};
|
|
26
42
|
}
|
|
27
43
|
|
|
@@ -29,12 +45,13 @@ function writeUrlState(state: FilterState) {
|
|
|
29
45
|
const url = new URL(window.location.href);
|
|
30
46
|
if (state.q) url.searchParams.set("q", state.q);
|
|
31
47
|
else url.searchParams.delete("q");
|
|
32
|
-
if (state.
|
|
33
|
-
else url.searchParams.delete("
|
|
34
|
-
if (state.
|
|
48
|
+
if (state.levels.size > 0) url.searchParams.set("level", [...state.levels].join(","));
|
|
49
|
+
else url.searchParams.delete("level");
|
|
50
|
+
if (state.tags.size > 0) url.searchParams.set("tag", [...state.tags].join(","));
|
|
35
51
|
else url.searchParams.delete("tag");
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
// Always strip the legacy key so an upgraded URL doesn't carry both
|
|
53
|
+
// shapes. First user interaction "migrates" the link.
|
|
54
|
+
url.searchParams.delete("difficulty");
|
|
38
55
|
window.history.replaceState({}, "", url.toString());
|
|
39
56
|
}
|
|
40
57
|
|
|
@@ -44,9 +61,12 @@ function applyFilters(state: FilterState) {
|
|
|
44
61
|
let visible = 0;
|
|
45
62
|
cards.forEach((card) => {
|
|
46
63
|
const matchesQ = !q || card.dataset.search!.includes(q);
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
const
|
|
64
|
+
const matchesLevel =
|
|
65
|
+
state.levels.size === 0 || state.levels.has(card.dataset.difficulty ?? "");
|
|
66
|
+
const cardTags = (card.dataset.tags ?? "").split(",");
|
|
67
|
+
const matchesTag =
|
|
68
|
+
state.tags.size === 0 || cardTags.some((t) => state.tags.has(t));
|
|
69
|
+
const show = matchesQ && matchesLevel && matchesTag;
|
|
50
70
|
if (show) {
|
|
51
71
|
card.removeAttribute("data-filter-hidden");
|
|
52
72
|
visible += 1;
|
|
@@ -54,98 +74,129 @@ function applyFilters(state: FilterState) {
|
|
|
54
74
|
card.setAttribute("data-filter-hidden", "");
|
|
55
75
|
}
|
|
56
76
|
});
|
|
77
|
+
// Empty state element controls its own visibility; we just set it.
|
|
78
|
+
// The Home.astro inline empty-state is shown when visible === 0.
|
|
57
79
|
const empty = document.querySelector<HTMLElement>("[data-empty-state]");
|
|
58
80
|
if (empty) empty.style.display = visible === 0 ? "" : "none";
|
|
59
|
-
//
|
|
60
|
-
//
|
|
81
|
+
// Results status line. Read total from rendered card count (server
|
|
82
|
+
// SSR'd them; localStorage / pagination don't change cards.length).
|
|
83
|
+
const status = document.querySelector<HTMLElement>("[data-results-status]");
|
|
84
|
+
if (status) {
|
|
85
|
+
const total = cards.length;
|
|
86
|
+
if (total === 0) {
|
|
87
|
+
status.textContent = "";
|
|
88
|
+
} else if (visible === total) {
|
|
89
|
+
status.textContent = `Showing all ${total} tutorial${total === 1 ? "" : "s"}`;
|
|
90
|
+
} else {
|
|
91
|
+
status.textContent = `Showing ${visible} of ${total} tutorial${total === 1 ? "" : "s"}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Fire once per state transition so Pagination resets to page 1.
|
|
61
95
|
window.dispatchEvent(new CustomEvent("hz:filter-changed"));
|
|
62
96
|
}
|
|
63
97
|
|
|
64
|
-
export default function FilterBar({
|
|
98
|
+
export default function FilterBar({
|
|
99
|
+
difficulties,
|
|
100
|
+
tags,
|
|
101
|
+
difficultyCounts,
|
|
102
|
+
tagCounts,
|
|
103
|
+
}: Props) {
|
|
65
104
|
const [state, setState] = useState<FilterState>(readUrlState);
|
|
66
105
|
|
|
67
106
|
useEffect(() => {
|
|
68
107
|
applyFilters(state);
|
|
69
108
|
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
109
|
}, [state]);
|
|
77
110
|
|
|
78
|
-
function
|
|
79
|
-
setState((prev) => ({ ...prev,
|
|
111
|
+
function setQ(q: string) {
|
|
112
|
+
setState((prev) => ({ ...prev, q }));
|
|
80
113
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
114
|
+
function setLevels(levels: Set<string>) {
|
|
115
|
+
setState((prev) => ({ ...prev, levels }));
|
|
116
|
+
}
|
|
117
|
+
function setTags(next: Set<string>) {
|
|
118
|
+
setState((prev) => ({ ...prev, tags: next }));
|
|
84
119
|
}
|
|
120
|
+
function removeLevel(level: string) {
|
|
121
|
+
setState((prev) => {
|
|
122
|
+
const next = new Set(prev.levels);
|
|
123
|
+
next.delete(level);
|
|
124
|
+
return { ...prev, levels: next };
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function removeTag(tag: string) {
|
|
128
|
+
setState((prev) => {
|
|
129
|
+
const next = new Set(prev.tags);
|
|
130
|
+
next.delete(tag);
|
|
131
|
+
return { ...prev, tags: next };
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function clearAll() {
|
|
135
|
+
setState({ q: "", levels: new Set(), tags: new Set() });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const levelOpts: MultiSelectOption[] = difficulties.map((d) => ({
|
|
139
|
+
value: d,
|
|
140
|
+
label: d,
|
|
141
|
+
count: difficultyCounts[d] ?? 0,
|
|
142
|
+
}));
|
|
143
|
+
const tagOpts: MultiSelectOption[] = tags.map((t) => ({
|
|
144
|
+
value: t,
|
|
145
|
+
label: `#${t}`,
|
|
146
|
+
count: tagCounts[t] ?? 0,
|
|
147
|
+
}));
|
|
85
148
|
|
|
86
|
-
const
|
|
149
|
+
const hasActive = state.q.length > 0 || state.levels.size > 0 || state.tags.size > 0;
|
|
87
150
|
|
|
88
151
|
return (
|
|
89
152
|
<div className="filterbar">
|
|
90
|
-
|
|
91
|
-
<div className="fb-row fb-row-primary">
|
|
153
|
+
<div className="fb-toolbar">
|
|
92
154
|
<label className="search">
|
|
93
155
|
<Search size={16} aria-hidden="true" />
|
|
94
156
|
<input
|
|
95
157
|
type="search"
|
|
96
158
|
placeholder="Search tutorials…"
|
|
97
159
|
value={state.q}
|
|
98
|
-
onChange={(e) =>
|
|
160
|
+
onChange={(e) => setQ(e.target.value)}
|
|
99
161
|
aria-label="Search tutorials"
|
|
100
162
|
/>
|
|
101
163
|
</label>
|
|
102
164
|
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
{d}
|
|
112
|
-
</button>
|
|
113
|
-
))}
|
|
114
|
-
</div>
|
|
165
|
+
<MultiSelect
|
|
166
|
+
id="hz-level"
|
|
167
|
+
label="Level"
|
|
168
|
+
values={state.levels}
|
|
169
|
+
onChange={setLevels}
|
|
170
|
+
options={levelOpts}
|
|
171
|
+
triggerIcon={<GraduationCap size={14} aria-hidden="true" />}
|
|
172
|
+
/>
|
|
115
173
|
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
</button>
|
|
126
|
-
</div>
|
|
174
|
+
<MultiSelect
|
|
175
|
+
id="hz-topics"
|
|
176
|
+
label="Topics"
|
|
177
|
+
values={state.tags}
|
|
178
|
+
onChange={setTags}
|
|
179
|
+
options={tagOpts}
|
|
180
|
+
searchable
|
|
181
|
+
triggerIcon={<Hash size={14} aria-hidden="true" />}
|
|
182
|
+
/>
|
|
127
183
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
)}
|
|
184
|
+
<span className="fb-divider" aria-hidden="true" />
|
|
185
|
+
<div className="fb-sort-slot">
|
|
186
|
+
<SortBar />
|
|
187
|
+
</div>
|
|
133
188
|
</div>
|
|
134
189
|
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
{
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
#{t}
|
|
146
|
-
</button>
|
|
147
|
-
))}
|
|
148
|
-
</div>
|
|
190
|
+
{hasActive && (
|
|
191
|
+
<ActiveFilterChips
|
|
192
|
+
q={state.q}
|
|
193
|
+
levels={state.levels}
|
|
194
|
+
tags={state.tags}
|
|
195
|
+
onClearQ={() => setQ("")}
|
|
196
|
+
onRemoveLevel={removeLevel}
|
|
197
|
+
onRemoveTag={removeTag}
|
|
198
|
+
onClearAll={clearAll}
|
|
199
|
+
/>
|
|
149
200
|
)}
|
|
150
201
|
</div>
|
|
151
202
|
);
|
|
@@ -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
|
+
}
|
|
@@ -4,8 +4,14 @@ import type { TutorialEntry } from "../../lib/content";
|
|
|
4
4
|
interface Props {
|
|
5
5
|
tutorial: TutorialEntry;
|
|
6
6
|
duration?: string;
|
|
7
|
+
/** Total number of steps in this tutorial. Used as the denominator
|
|
8
|
+
* for the per-card progress bar — has to come from the server-side
|
|
9
|
+
* content collection because localStorage only holds states for the
|
|
10
|
+
* steps the user has visited (so a fresh learner who completed 1 of
|
|
11
|
+
* 4 steps would otherwise see 1/1). */
|
|
12
|
+
stepCount: number;
|
|
7
13
|
}
|
|
8
|
-
const { tutorial, duration } = Astro.props;
|
|
14
|
+
const { tutorial, duration, stepCount } = Astro.props;
|
|
9
15
|
const data = tutorial.data;
|
|
10
16
|
const slug = tutorial.id;
|
|
11
17
|
---
|
|
@@ -42,6 +48,11 @@ const slug = tutorial.id;
|
|
|
42
48
|
{duration}
|
|
43
49
|
</span>
|
|
44
50
|
)}
|
|
51
|
+
<span
|
|
52
|
+
class="card-progress"
|
|
53
|
+
data-tutorial-slug={slug}
|
|
54
|
+
data-step-count={stepCount}
|
|
55
|
+
></span>
|
|
45
56
|
</div>
|
|
46
57
|
{data.tags.length > 0 && (
|
|
47
58
|
<div class="tags">
|
|
@@ -53,7 +64,6 @@ const slug = tutorial.id;
|
|
|
53
64
|
data-tutorial-stats-slug={slug}
|
|
54
65
|
aria-hidden="true"
|
|
55
66
|
></div>
|
|
56
|
-
<div class="card-progress" data-tutorial-slug={slug}></div>
|
|
57
67
|
</div>
|
|
58
68
|
</a>
|
|
59
69
|
</article>
|
|
@@ -83,14 +93,16 @@ const slug = tutorial.id;
|
|
|
83
93
|
flex: 1;
|
|
84
94
|
}
|
|
85
95
|
|
|
86
|
-
/* Metadata row: difficulty + duration
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
96
|
+
/* Metadata row: difficulty + duration with a divider beneath it.
|
|
97
|
+
* `margin-top: auto` pins this row (and everything below it — tags,
|
|
98
|
+
* stats, progress) to the bottom of the card so all cards in the
|
|
99
|
+
* grid share the same bottom alignment regardless of how short the
|
|
100
|
+
* description is. */
|
|
90
101
|
.badges {
|
|
91
102
|
display: flex;
|
|
92
103
|
gap: 0.4rem;
|
|
93
|
-
margin-top:
|
|
104
|
+
margin-top: auto;
|
|
105
|
+
padding-top: 0.85rem;
|
|
94
106
|
padding-bottom: 0.85rem;
|
|
95
107
|
border-bottom: 1px solid var(--color-border);
|
|
96
108
|
flex-wrap: wrap;
|
|
@@ -159,7 +171,44 @@ const slug = tutorial.id;
|
|
|
159
171
|
color: var(--color-muted);
|
|
160
172
|
}
|
|
161
173
|
|
|
162
|
-
|
|
174
|
+
/* Per-card completion ring, hydrated by the home-page script.
|
|
175
|
+
* Sits inside the `.badges` row, pushed to the right edge so it
|
|
176
|
+
* lines up with the level + duration chips on the same baseline. */
|
|
177
|
+
.card-progress {
|
|
178
|
+
margin-left: auto;
|
|
179
|
+
display: inline-flex;
|
|
180
|
+
align-items: center;
|
|
181
|
+
gap: 0.4rem;
|
|
182
|
+
font-family: var(--font-mono);
|
|
183
|
+
font-size: 0.68em;
|
|
184
|
+
color: var(--color-muted);
|
|
185
|
+
text-transform: uppercase;
|
|
186
|
+
letter-spacing: 0.08em;
|
|
187
|
+
}
|
|
188
|
+
.card-progress:empty { display: none; }
|
|
189
|
+
/* The ring + label live inside `.card-progress` but are injected
|
|
190
|
+
* via innerHTML at runtime, so Astro's scope hash doesn't apply to
|
|
191
|
+
* them. Use :global() so the rules still match. */
|
|
192
|
+
.card-progress :global(.ring) {
|
|
193
|
+
/* Start the fill at 12 o'clock. */
|
|
194
|
+
transform: rotate(-90deg);
|
|
195
|
+
flex-shrink: 0;
|
|
196
|
+
}
|
|
197
|
+
.card-progress :global(.ring-track) {
|
|
198
|
+
fill: none;
|
|
199
|
+
stroke: var(--color-border);
|
|
200
|
+
stroke-width: 2.5;
|
|
201
|
+
}
|
|
202
|
+
.card-progress :global(.ring-fill) {
|
|
203
|
+
fill: none;
|
|
204
|
+
stroke: var(--color-accent);
|
|
205
|
+
stroke-width: 2.5;
|
|
206
|
+
stroke-linecap: round;
|
|
207
|
+
transition: stroke-dashoffset 0.3s ease;
|
|
208
|
+
}
|
|
209
|
+
.card-progress :global(.ring-label) {
|
|
210
|
+
color: var(--color-accent);
|
|
211
|
+
}
|
|
163
212
|
|
|
164
213
|
/* Cross-learner popularity, hydrated from /api/tutorials/stats.
|
|
165
214
|
* Hidden until the script populates content so the card height
|