handzon-core 0.15.2 → 0.15.4
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 +1 -1
- package/src/components/auth/UserMenu.astro +12 -23
- package/src/components/auth/UserMenu.tsx +1 -3
- package/src/components/home/ActiveFilterChips.tsx +16 -0
- package/src/components/home/FilterBar.tsx +37 -6
- package/src/components/home/TutorialCard.astro +55 -0
- package/src/lib/icons.ts +3 -0
- package/src/lib/progress/types.ts +6 -0
- package/src/lib/progress/useProgress.ts +13 -0
- package/src/pages/Home.astro +82 -9
- package/styles/components/filterbar.css +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handzon-core",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.4",
|
|
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"
|
|
@@ -14,29 +14,13 @@ import UserMenuIsland from "./UserMenu.tsx";
|
|
|
14
14
|
// fallback to the cached signed-in state, removing the nav reload flash.
|
|
15
15
|
const AUTH_SNAPSHOT_KEY = "hz-auth-snapshot";
|
|
16
16
|
const TOKENS_HREF = withBase("/settings/tokens");
|
|
17
|
-
|
|
18
|
-
const GITHUB_ICON_PATH =
|
|
19
|
-
"M12 .5C5.65.5.5 5.65 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.55v-2.02c-3.2.7-3.88-1.36-3.88-1.36-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.74.4-1.25.72-1.54-2.55-.29-5.24-1.27-5.24-5.66 0-1.25.45-2.27 1.18-3.07-.12-.29-.51-1.45.11-3.03 0 0 .96-.31 3.15 1.17a10.94 10.94 0 0 1 5.76 0c2.19-1.48 3.15-1.17 3.15-1.17.62 1.58.23 2.74.11 3.03.74.8 1.18 1.82 1.18 3.07 0 4.4-2.69 5.37-5.25 5.65.41.35.78 1.04.78 2.11v3.13c0 .3.21.66.79.55C20.71 21.39 24 17.08 24 12 24 5.65 18.85.5 12 .5z";
|
|
20
17
|
---
|
|
21
18
|
<div class="user-menu-shell">
|
|
22
19
|
<div class="user-menu um-fallback" data-user-menu-fallback aria-hidden="true">
|
|
23
|
-
{/*
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
<svg
|
|
28
|
-
class="um-gh"
|
|
29
|
-
viewBox="0 0 24 24"
|
|
30
|
-
width="14"
|
|
31
|
-
height="14"
|
|
32
|
-
fill="currentColor"
|
|
33
|
-
aria-hidden="true"
|
|
34
|
-
>
|
|
35
|
-
<path d={GITHUB_ICON_PATH} />
|
|
36
|
-
</svg>
|
|
37
|
-
<span>Sign in with GitHub</span>
|
|
38
|
-
</button>
|
|
39
|
-
</span>
|
|
20
|
+
{/* Neutral placeholder. The static page cannot read Auth.js' HttpOnly
|
|
21
|
+
session cookie, so do not show a signed-out GitHub button until
|
|
22
|
+
the client fetch resolves. */}
|
|
23
|
+
<span class="um-fallback-variant um-fallback-spacer" data-um-fallback-spacer></span>
|
|
40
24
|
{/* Signed-in placeholder. Hidden until the inline script fills in the
|
|
41
25
|
cached avatar + name and reveals it. Mirrors the island's
|
|
42
26
|
signed-in layout so the swap on hydration is invisible. */}
|
|
@@ -97,9 +81,9 @@ const GITHUB_ICON_PATH =
|
|
|
97
81
|
if (!user) return; // signed out → keep the default placeholder
|
|
98
82
|
var root = document.querySelector("[data-user-menu-fallback]");
|
|
99
83
|
if (!root) return;
|
|
100
|
-
var
|
|
84
|
+
var spacer = root.querySelector("[data-um-fallback-spacer]");
|
|
101
85
|
var signedIn = root.querySelector("[data-um-fallback-signedin]");
|
|
102
|
-
if (!
|
|
86
|
+
if (!spacer || !signedIn) return;
|
|
103
87
|
|
|
104
88
|
var label = user.name || user.email || "Signed in";
|
|
105
89
|
var first = String(label).trim().split(/\s+/)[0] || "Signed in";
|
|
@@ -118,7 +102,7 @@ const GITHUB_ICON_PATH =
|
|
|
118
102
|
initial.textContent = label.trim().charAt(0).toUpperCase();
|
|
119
103
|
initial.hidden = false;
|
|
120
104
|
}
|
|
121
|
-
|
|
105
|
+
spacer.hidden = true;
|
|
122
106
|
signedIn.hidden = false;
|
|
123
107
|
} catch (e) {
|
|
124
108
|
/* ignore — fall back to the default signed-out placeholder */
|
|
@@ -180,6 +164,11 @@ const GITHUB_ICON_PATH =
|
|
|
180
164
|
.um-fallback-variant[hidden] {
|
|
181
165
|
display: none;
|
|
182
166
|
}
|
|
167
|
+
.um-fallback-spacer {
|
|
168
|
+
display: inline-block;
|
|
169
|
+
width: 11.5rem;
|
|
170
|
+
height: calc(1em + 0.6rem + 2px);
|
|
171
|
+
}
|
|
183
172
|
.um-btn {
|
|
184
173
|
display: inline-flex;
|
|
185
174
|
align-items: center;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useState } from "react";
|
|
2
2
|
import { withBase } from "../../lib/base";
|
|
3
|
+
import { GITHUB_ICON_PATH } from "../../lib/icons";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Client-only auth menu. Fetches `/api/auth/session` + `/api/auth/csrf`
|
|
@@ -77,9 +78,6 @@ function clearAuthSnapshot() {
|
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
const GITHUB_ICON_PATH =
|
|
81
|
-
"M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.55v-2.02c-3.2.7-3.88-1.36-3.88-1.36-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.74.4-1.25.72-1.54-2.55-.29-5.24-1.27-5.24-5.66 0-1.25.45-2.27 1.18-3.07-.12-.29-.51-1.45.11-3.03 0 0 .96-.31 3.15 1.17a10.94 10.94 0 0 1 5.76 0c2.19-1.48 3.15-1.17 3.15-1.17.62 1.58.23 2.74.11 3.03.74.8 1.18 1.82 1.18 3.07 0 4.4-2.69 5.37-5.25 5.65.41.35.78 1.04.78 2.11v3.13c0 .3.21.66.79.55C20.71 21.39 24 17.08 24 12 24 5.65 18.85.5 12 .5z";
|
|
82
|
-
|
|
83
81
|
export default function UserMenu() {
|
|
84
82
|
// `undefined` = not yet loaded; `null` = no auth or signed out;
|
|
85
83
|
// object = signed in. Seeded from the last-known snapshot so a
|
|
@@ -5,9 +5,11 @@ interface Props {
|
|
|
5
5
|
q: string;
|
|
6
6
|
levels: Set<string>;
|
|
7
7
|
tags: Set<string>;
|
|
8
|
+
favoritesOnly: boolean;
|
|
8
9
|
onClearQ: () => void;
|
|
9
10
|
onRemoveLevel: (level: string) => void;
|
|
10
11
|
onRemoveTag: (tag: string) => void;
|
|
12
|
+
onClearFavorites: () => void;
|
|
11
13
|
onClearAll: () => void;
|
|
12
14
|
}
|
|
13
15
|
|
|
@@ -27,9 +29,11 @@ export default function ActiveFilterChips({
|
|
|
27
29
|
q,
|
|
28
30
|
levels,
|
|
29
31
|
tags,
|
|
32
|
+
favoritesOnly,
|
|
30
33
|
onClearQ,
|
|
31
34
|
onRemoveLevel,
|
|
32
35
|
onRemoveTag,
|
|
36
|
+
onClearFavorites,
|
|
33
37
|
onClearAll,
|
|
34
38
|
}: Props) {
|
|
35
39
|
function chipKey(e: KeyboardEvent<HTMLButtonElement>, remove: () => void) {
|
|
@@ -80,6 +84,18 @@ export default function ActiveFilterChips({
|
|
|
80
84
|
<X size={12} aria-hidden="true" />
|
|
81
85
|
</button>
|
|
82
86
|
))}
|
|
87
|
+
{favoritesOnly && (
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
className="active-filter-chip"
|
|
91
|
+
aria-label="Remove favorites filter"
|
|
92
|
+
onClick={onClearFavorites}
|
|
93
|
+
onKeyDown={(e) => chipKey(e, onClearFavorites)}
|
|
94
|
+
>
|
|
95
|
+
<span className="afc-label">favorites</span>
|
|
96
|
+
<X size={12} aria-hidden="true" />
|
|
97
|
+
</button>
|
|
98
|
+
)}
|
|
83
99
|
<button type="button" className="active-filter-clear" onClick={onClearAll}>
|
|
84
100
|
Clear all
|
|
85
101
|
</button>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { GraduationCap, Hash, Search } from "lucide-react";
|
|
2
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { GraduationCap, Hash, Search, Star } from "lucide-react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
import MultiSelect, { type MultiSelectOption } from "../ui/MultiSelect.tsx";
|
|
4
4
|
import ActiveFilterChips from "./ActiveFilterChips.tsx";
|
|
5
5
|
import SortBar from "./SortBar.tsx";
|
|
@@ -17,6 +17,7 @@ interface FilterState {
|
|
|
17
17
|
q: string;
|
|
18
18
|
levels: Set<string>;
|
|
19
19
|
tags: Set<string>;
|
|
20
|
+
favoritesOnly: boolean;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
function parseCsv(value: string | null): Set<string> {
|
|
@@ -31,7 +32,7 @@ function parseCsv(value: string | null): Set<string> {
|
|
|
31
32
|
|
|
32
33
|
function readUrlState(): FilterState {
|
|
33
34
|
if (typeof window === "undefined") {
|
|
34
|
-
return { q: "", levels: new Set(), tags: new Set() };
|
|
35
|
+
return { q: "", levels: new Set(), tags: new Set(), favoritesOnly: false };
|
|
35
36
|
}
|
|
36
37
|
const url = new URL(window.location.href);
|
|
37
38
|
const levels = url.searchParams.get("level")
|
|
@@ -43,6 +44,7 @@ function readUrlState(): FilterState {
|
|
|
43
44
|
q: url.searchParams.get("q") ?? "",
|
|
44
45
|
levels,
|
|
45
46
|
tags: parseCsv(url.searchParams.get("tag")),
|
|
47
|
+
favoritesOnly: url.searchParams.get("favorites") === "1",
|
|
46
48
|
};
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -54,6 +56,8 @@ function writeUrlState(state: FilterState) {
|
|
|
54
56
|
else url.searchParams.delete("level");
|
|
55
57
|
if (state.tags.size > 0) url.searchParams.set("tag", [...state.tags].join(","));
|
|
56
58
|
else url.searchParams.delete("tag");
|
|
59
|
+
if (state.favoritesOnly) url.searchParams.set("favorites", "1");
|
|
60
|
+
else url.searchParams.delete("favorites");
|
|
57
61
|
// Always strip the legacy key so an upgraded URL doesn't carry both
|
|
58
62
|
// shapes. First user interaction "migrates" the link.
|
|
59
63
|
url.searchParams.delete("difficulty");
|
|
@@ -69,7 +73,8 @@ function applyFilters(state: FilterState) {
|
|
|
69
73
|
const matchesLevel = state.levels.size === 0 || state.levels.has(card.dataset.difficulty ?? "");
|
|
70
74
|
const cardTags = (card.dataset.tags ?? "").split(",");
|
|
71
75
|
const matchesTag = state.tags.size === 0 || cardTags.some((t) => state.tags.has(t));
|
|
72
|
-
const
|
|
76
|
+
const matchesFavorite = !state.favoritesOnly || card.hasAttribute("data-favorited");
|
|
77
|
+
const show = matchesQ && matchesLevel && matchesTag && matchesFavorite;
|
|
73
78
|
if (show) {
|
|
74
79
|
card.removeAttribute("data-filter-hidden");
|
|
75
80
|
visible += 1;
|
|
@@ -100,12 +105,22 @@ function applyFilters(state: FilterState) {
|
|
|
100
105
|
|
|
101
106
|
export default function FilterBar({ difficulties, tags, difficultyCounts, tagCounts }: Props) {
|
|
102
107
|
const [state, setState] = useState<FilterState>(readUrlState);
|
|
108
|
+
const stateRef = useRef(state);
|
|
103
109
|
|
|
104
110
|
useEffect(() => {
|
|
111
|
+
stateRef.current = state;
|
|
105
112
|
applyFilters(state);
|
|
106
113
|
writeUrlState(state);
|
|
107
114
|
}, [state]);
|
|
108
115
|
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
function onFavoritesChanged() {
|
|
118
|
+
applyFilters(stateRef.current);
|
|
119
|
+
}
|
|
120
|
+
window.addEventListener("hz:favorites-changed", onFavoritesChanged);
|
|
121
|
+
return () => window.removeEventListener("hz:favorites-changed", onFavoritesChanged);
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
109
124
|
function setQ(q: string) {
|
|
110
125
|
setState((prev) => ({ ...prev, q }));
|
|
111
126
|
}
|
|
@@ -115,6 +130,9 @@ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCou
|
|
|
115
130
|
function setTags(next: Set<string>) {
|
|
116
131
|
setState((prev) => ({ ...prev, tags: next }));
|
|
117
132
|
}
|
|
133
|
+
function setFavoritesOnly(favoritesOnly: boolean) {
|
|
134
|
+
setState((prev) => ({ ...prev, favoritesOnly }));
|
|
135
|
+
}
|
|
118
136
|
function removeLevel(level: string) {
|
|
119
137
|
setState((prev) => {
|
|
120
138
|
const next = new Set(prev.levels);
|
|
@@ -130,7 +148,7 @@ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCou
|
|
|
130
148
|
});
|
|
131
149
|
}
|
|
132
150
|
function clearAll() {
|
|
133
|
-
setState({ q: "", levels: new Set(), tags: new Set() });
|
|
151
|
+
setState({ q: "", levels: new Set(), tags: new Set(), favoritesOnly: false });
|
|
134
152
|
}
|
|
135
153
|
|
|
136
154
|
const levelOpts: MultiSelectOption[] = difficulties.map((d) => ({
|
|
@@ -144,7 +162,8 @@ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCou
|
|
|
144
162
|
count: tagCounts[t] ?? 0,
|
|
145
163
|
}));
|
|
146
164
|
|
|
147
|
-
const hasActive =
|
|
165
|
+
const hasActive =
|
|
166
|
+
state.q.length > 0 || state.levels.size > 0 || state.tags.size > 0 || state.favoritesOnly;
|
|
148
167
|
|
|
149
168
|
return (
|
|
150
169
|
<div className="filterbar">
|
|
@@ -179,6 +198,16 @@ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCou
|
|
|
179
198
|
triggerIcon={<Hash size={14} aria-hidden="true" />}
|
|
180
199
|
/>
|
|
181
200
|
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
className="favorites-filter"
|
|
204
|
+
aria-pressed={state.favoritesOnly}
|
|
205
|
+
onClick={() => setFavoritesOnly(!state.favoritesOnly)}
|
|
206
|
+
>
|
|
207
|
+
<Star size={14} aria-hidden="true" />
|
|
208
|
+
Favorites
|
|
209
|
+
</button>
|
|
210
|
+
|
|
182
211
|
<span className="fb-divider" aria-hidden="true" />
|
|
183
212
|
<div className="fb-sort-slot">
|
|
184
213
|
<SortBar />
|
|
@@ -190,9 +219,11 @@ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCou
|
|
|
190
219
|
q={state.q}
|
|
191
220
|
levels={state.levels}
|
|
192
221
|
tags={state.tags}
|
|
222
|
+
favoritesOnly={state.favoritesOnly}
|
|
193
223
|
onClearQ={() => setQ("")}
|
|
194
224
|
onRemoveLevel={removeLevel}
|
|
195
225
|
onRemoveTag={removeTag}
|
|
226
|
+
onClearFavorites={() => setFavoritesOnly(false)}
|
|
196
227
|
onClearAll={clearAll}
|
|
197
228
|
/>
|
|
198
229
|
)}
|
|
@@ -37,6 +37,30 @@ const defaultCover = getDefaultCoverMeta({
|
|
|
37
37
|
data-card-slug={slug}
|
|
38
38
|
data-popularity="0"
|
|
39
39
|
>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
class="card-fav"
|
|
43
|
+
data-fav-slug={slug}
|
|
44
|
+
aria-pressed="false"
|
|
45
|
+
aria-label={`Add ${data.title} to favorites`}
|
|
46
|
+
>
|
|
47
|
+
<svg
|
|
48
|
+
viewBox="0 0 24 24"
|
|
49
|
+
width="18"
|
|
50
|
+
height="18"
|
|
51
|
+
fill="none"
|
|
52
|
+
stroke="currentColor"
|
|
53
|
+
stroke-width="2"
|
|
54
|
+
stroke-linecap="round"
|
|
55
|
+
stroke-linejoin="round"
|
|
56
|
+
aria-hidden="true"
|
|
57
|
+
>
|
|
58
|
+
<path
|
|
59
|
+
d="M11.48 3.5a.59.59 0 0 1 1.04 0l2.38 4.82a.59.59 0 0 0 .44.32l5.32.77a.59.59 0 0 1 .33 1l-3.85 3.75a.59.59 0 0 0-.17.52l.91 5.3a.59.59 0 0 1-.86.62l-4.76-2.5a.59.59 0 0 0-.55 0l-4.76 2.5a.59.59 0 0 1-.86-.62l.91-5.3a.59.59 0 0 0-.17-.52L3.01 10.4a.59.59 0 0 1 .33-1l5.32-.77a.59.59 0 0 0 .44-.32l2.38-4.82Z"
|
|
60
|
+
/>
|
|
61
|
+
</svg>
|
|
62
|
+
<span class="sr-only">Favorite</span>
|
|
63
|
+
</button>
|
|
40
64
|
<a href={withBase(`/${slug}`)} class="card-link">
|
|
41
65
|
<div class="card-media">
|
|
42
66
|
{
|
|
@@ -151,6 +175,7 @@ const defaultCover = getDefaultCoverMeta({
|
|
|
151
175
|
border: 1px solid var(--color-border);
|
|
152
176
|
background: var(--color-bg);
|
|
153
177
|
display: flex;
|
|
178
|
+
position: relative;
|
|
154
179
|
transition:
|
|
155
180
|
border-color 0.12s ease,
|
|
156
181
|
transform 0.12s ease;
|
|
@@ -166,6 +191,36 @@ const defaultCover = getDefaultCoverMeta({
|
|
|
166
191
|
flex-direction: column;
|
|
167
192
|
width: 100%;
|
|
168
193
|
}
|
|
194
|
+
.card-fav {
|
|
195
|
+
position: absolute;
|
|
196
|
+
top: 0.35rem;
|
|
197
|
+
right: 0.35rem;
|
|
198
|
+
z-index: 5;
|
|
199
|
+
display: inline-grid;
|
|
200
|
+
place-items: center;
|
|
201
|
+
width: 2rem;
|
|
202
|
+
height: 2rem;
|
|
203
|
+
padding: 0;
|
|
204
|
+
border: 0;
|
|
205
|
+
background: transparent;
|
|
206
|
+
color: var(--color-muted);
|
|
207
|
+
cursor: pointer;
|
|
208
|
+
transition:
|
|
209
|
+
color 0.12s ease,
|
|
210
|
+
transform 0.12s ease;
|
|
211
|
+
}
|
|
212
|
+
.card-fav:hover,
|
|
213
|
+
.card-fav:focus-visible {
|
|
214
|
+
color: var(--color-fg);
|
|
215
|
+
outline: none;
|
|
216
|
+
transform: scale(1.04);
|
|
217
|
+
}
|
|
218
|
+
.card-fav[aria-pressed="true"] {
|
|
219
|
+
color: #fff;
|
|
220
|
+
}
|
|
221
|
+
.card-fav[aria-pressed="true"] svg {
|
|
222
|
+
fill: currentColor;
|
|
223
|
+
}
|
|
169
224
|
.card-media {
|
|
170
225
|
aspect-ratio: 16 / 9;
|
|
171
226
|
border-bottom: 1px solid var(--color-border);
|
package/src/lib/icons.ts
ADDED
|
@@ -35,6 +35,11 @@ export type ProgressState = {
|
|
|
35
35
|
steps: Record<StepKey, "incomplete" | "complete">;
|
|
36
36
|
quizzes: Record<string, { chosen: number[]; correct: boolean; ts: number }>;
|
|
37
37
|
checkpoints: Record<string, { ts: number }>;
|
|
38
|
+
/**
|
|
39
|
+
* Per-learner favorite tutorials. Keyed by tutorial slug; the value is
|
|
40
|
+
* the timestamp when the learner last favorited it.
|
|
41
|
+
*/
|
|
42
|
+
favorites: Record<string, number>;
|
|
38
43
|
/**
|
|
39
44
|
* Latest verification verdict per checkpoint id. `pass: true`
|
|
40
45
|
* entries hang around as evidence; the Family D UI only renders
|
|
@@ -73,6 +78,7 @@ export const emptyState = (): ProgressState => ({
|
|
|
73
78
|
steps: {},
|
|
74
79
|
quizzes: {},
|
|
75
80
|
checkpoints: {},
|
|
81
|
+
favorites: {},
|
|
76
82
|
verificationFeedback: {},
|
|
77
83
|
prefs: {},
|
|
78
84
|
lastVisited: {},
|
|
@@ -16,7 +16,9 @@ interface ProgressApi {
|
|
|
16
16
|
setLastVisited: (tutorial: string, step: string) => void;
|
|
17
17
|
markTutorialStarted: (tutorial: string) => void;
|
|
18
18
|
markTutorialCompleted: (tutorial: string) => void;
|
|
19
|
+
toggleFavorite: (tutorial: string) => void;
|
|
19
20
|
isStepComplete: (tutorial: string, step: string) => boolean;
|
|
21
|
+
isFavorite: (tutorial: string) => boolean;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -100,6 +102,16 @@ export function useProgress(): ProgressApi {
|
|
|
100
102
|
},
|
|
101
103
|
};
|
|
102
104
|
}),
|
|
105
|
+
toggleFavorite: (tutorial: string) =>
|
|
106
|
+
store.set((s) => {
|
|
107
|
+
const nextFavorites = { ...s.favorites };
|
|
108
|
+
if (nextFavorites[tutorial]) {
|
|
109
|
+
delete nextFavorites[tutorial];
|
|
110
|
+
} else {
|
|
111
|
+
nextFavorites[tutorial] = Date.now();
|
|
112
|
+
}
|
|
113
|
+
return { ...s, favorites: nextFavorites };
|
|
114
|
+
}),
|
|
103
115
|
};
|
|
104
116
|
}, [store]);
|
|
105
117
|
|
|
@@ -108,6 +120,7 @@ export function useProgress(): ProgressApi {
|
|
|
108
120
|
...actions,
|
|
109
121
|
isStepComplete: (tutorial, step) =>
|
|
110
122
|
state.steps[`${tutorial}/${step}` as StepKey] === "complete",
|
|
123
|
+
isFavorite: (tutorial) => !!state.favorites[tutorial],
|
|
111
124
|
};
|
|
112
125
|
}
|
|
113
126
|
|
package/src/pages/Home.astro
CHANGED
|
@@ -168,6 +168,64 @@ for (const t of tutorials) {
|
|
|
168
168
|
getStore().subscribe(refresh);
|
|
169
169
|
</script>
|
|
170
170
|
|
|
171
|
+
<script>
|
|
172
|
+
// Hydrate per-card favorites from the same local progress store used
|
|
173
|
+
// by steps and preferences. Cards expose `data-favorited` so the
|
|
174
|
+
// FilterBar can compose favorites with search, facets, and pagination.
|
|
175
|
+
import { getStore } from "../lib/progress/local.ts";
|
|
176
|
+
const store = getStore();
|
|
177
|
+
let lastFavoritesSignature: string | null = null;
|
|
178
|
+
|
|
179
|
+
function favoriteSignature(favorites: Record<string, number>): string {
|
|
180
|
+
return Object.keys(favorites).sort().join("\0");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function paintFavorites() {
|
|
184
|
+
const favorites = store.get().favorites ?? {};
|
|
185
|
+
const favoriteSlugs = new Set(Object.keys(favorites));
|
|
186
|
+
const signature = favoriteSignature(favorites);
|
|
187
|
+
document.querySelectorAll<HTMLButtonElement>("[data-fav-slug]").forEach((button) => {
|
|
188
|
+
const slug = button.dataset.favSlug!;
|
|
189
|
+
const card = button.closest<HTMLElement>("[data-card-slug]");
|
|
190
|
+
const title = card?.querySelector("h3")?.textContent?.trim() ?? slug;
|
|
191
|
+
const isFavorite = favoriteSlugs.has(slug);
|
|
192
|
+
button.setAttribute("aria-pressed", String(isFavorite));
|
|
193
|
+
button.setAttribute(
|
|
194
|
+
"aria-label",
|
|
195
|
+
isFavorite ? `Remove ${title} from favorites` : `Add ${title} to favorites`,
|
|
196
|
+
);
|
|
197
|
+
if (isFavorite) {
|
|
198
|
+
card?.setAttribute("data-favorited", "");
|
|
199
|
+
} else {
|
|
200
|
+
card?.removeAttribute("data-favorited");
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
if (signature !== lastFavoritesSignature) {
|
|
204
|
+
lastFavoritesSignature = signature;
|
|
205
|
+
window.dispatchEvent(new CustomEvent("hz:favorites-changed"));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
document.querySelectorAll<HTMLButtonElement>("[data-fav-slug]").forEach((button) => {
|
|
210
|
+
button.addEventListener("click", () => {
|
|
211
|
+
const slug = button.dataset.favSlug;
|
|
212
|
+
if (!slug) return;
|
|
213
|
+
store.set((state) => {
|
|
214
|
+
const favorites = { ...state.favorites };
|
|
215
|
+
if (favorites[slug]) {
|
|
216
|
+
delete favorites[slug];
|
|
217
|
+
} else {
|
|
218
|
+
favorites[slug] = Date.now();
|
|
219
|
+
}
|
|
220
|
+
return { ...state, favorites };
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
paintFavorites();
|
|
226
|
+
store.subscribe(paintFavorites);
|
|
227
|
+
</script>
|
|
228
|
+
|
|
171
229
|
<script>
|
|
172
230
|
// Hydrate cross-learner popularity numbers and the `data-popularity`
|
|
173
231
|
// attribute used by the FilterBar's "Popular" sort. Single fetch on
|
|
@@ -217,22 +275,36 @@ for (const t of tutorials) {
|
|
|
217
275
|
</script>
|
|
218
276
|
|
|
219
277
|
<script>
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
278
|
+
// Home-page ordering: tutorials this learner already started come
|
|
279
|
+
// first. Within that group, either keep the curated server order or
|
|
280
|
+
// apply the selected popularity sort.
|
|
281
|
+
import { getStore } from "../lib/progress/local.ts";
|
|
282
|
+
const store = getStore();
|
|
283
|
+
|
|
284
|
+
function hasStarted(card: HTMLElement): boolean {
|
|
285
|
+
const slug = card.dataset.cardSlug;
|
|
286
|
+
if (!slug) return false;
|
|
287
|
+
return !!store.get().tutorials[slug]?.started;
|
|
288
|
+
}
|
|
289
|
+
|
|
224
290
|
function applySort(mode: string) {
|
|
225
|
-
const cards = document.querySelectorAll<HTMLElement>("[data-card-slug]");
|
|
291
|
+
const cards = Array.from(document.querySelectorAll<HTMLElement>("[data-card-slug]"));
|
|
292
|
+
const originalOrder = new Map(cards.map((card, idx) => [card, idx]));
|
|
226
293
|
if (mode === "popular") {
|
|
227
|
-
const sorted =
|
|
228
|
-
|
|
294
|
+
const sorted = cards.sort((a, b) => {
|
|
295
|
+
const startedDelta = Number(hasStarted(b)) - Number(hasStarted(a));
|
|
296
|
+
if (startedDelta !== 0) return startedDelta;
|
|
297
|
+
const popularityDelta =
|
|
298
|
+
Number(b.dataset.popularity ?? 0) - Number(a.dataset.popularity ?? 0);
|
|
299
|
+
if (popularityDelta !== 0) return popularityDelta;
|
|
300
|
+
return (originalOrder.get(a) ?? 0) - (originalOrder.get(b) ?? 0);
|
|
229
301
|
});
|
|
230
302
|
sorted.forEach((card, idx) => {
|
|
231
303
|
card.style.order = String(idx);
|
|
232
304
|
});
|
|
233
305
|
} else {
|
|
234
|
-
cards.forEach((card) => {
|
|
235
|
-
card.style.
|
|
306
|
+
cards.forEach((card, idx) => {
|
|
307
|
+
card.style.order = String(hasStarted(card) ? idx : idx + cards.length);
|
|
236
308
|
});
|
|
237
309
|
}
|
|
238
310
|
}
|
|
@@ -245,6 +317,7 @@ for (const t of tutorials) {
|
|
|
245
317
|
applySort((e as CustomEvent<{ sort: string }>).detail.sort);
|
|
246
318
|
});
|
|
247
319
|
window.addEventListener("hz:stats-loaded", syncFromUrl);
|
|
320
|
+
store.subscribe(syncFromUrl);
|
|
248
321
|
</script>
|
|
249
322
|
</BaseLayout>
|
|
250
323
|
|
|
@@ -65,6 +65,35 @@
|
|
|
65
65
|
flex-shrink: 0;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
.favorites-filter {
|
|
69
|
+
display: inline-flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
gap: 0.45rem;
|
|
72
|
+
min-height: 2.5rem;
|
|
73
|
+
padding: 0.4rem 0.65rem;
|
|
74
|
+
background: transparent;
|
|
75
|
+
border: 1px solid var(--color-border);
|
|
76
|
+
color: var(--color-fg);
|
|
77
|
+
font-family: var(--font-mono);
|
|
78
|
+
font-size: 0.78em;
|
|
79
|
+
cursor: pointer;
|
|
80
|
+
transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
|
|
81
|
+
}
|
|
82
|
+
.favorites-filter:hover,
|
|
83
|
+
.favorites-filter:focus-visible {
|
|
84
|
+
border-color: var(--color-accent);
|
|
85
|
+
color: var(--color-accent);
|
|
86
|
+
outline: none;
|
|
87
|
+
}
|
|
88
|
+
.favorites-filter[aria-pressed="true"] {
|
|
89
|
+
border-color: var(--color-accent);
|
|
90
|
+
color: var(--color-accent);
|
|
91
|
+
background: color-mix(in oklab, var(--color-accent) 8%, transparent);
|
|
92
|
+
}
|
|
93
|
+
.favorites-filter[aria-pressed="true"] svg {
|
|
94
|
+
fill: currentColor;
|
|
95
|
+
}
|
|
96
|
+
|
|
68
97
|
/* On narrow viewports the toolbar wraps; sort drops to its own line.
|
|
69
98
|
* Keep the divider visible only when the row hasn't wrapped — when it
|
|
70
99
|
* has, the layout already reads as distinct rows. Cheap heuristic:
|