handzon-core 0.15.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handzon-core",
3
- "version": "0.15.3",
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"
@@ -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 show = matchesQ && matchesLevel && matchesTag;
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 = state.q.length > 0 || state.levels.size > 0 || state.tags.size > 0;
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);
@@ -0,0 +1,3 @@
1
+ import { siGithub } from "simple-icons";
2
+
3
+ export const GITHUB_ICON_PATH = siGithub.path;
@@ -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
 
@@ -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
- // Popularity sort: re-orders cards in place by writing CSS `order`
221
- // from `data-popularity`. Composes with the existing filter/page
222
- // hide attributes because order is a flex/grid layout concern, not
223
- // a visibility concern.
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 = Array.from(cards).sort((a, b) => {
228
- return Number(b.dataset.popularity ?? 0) - Number(a.dataset.popularity ?? 0);
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.removeProperty("order");
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: