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/src/pages/Home.astro
CHANGED
|
@@ -41,6 +41,16 @@ const stepsByTutorial = new Map(
|
|
|
41
41
|
const difficulties = ["beginner", "intermediate", "advanced"];
|
|
42
42
|
const tags = Array.from(new Set(tutorials.flatMap((t) => t.data.tags))).sort();
|
|
43
43
|
const compactTutorials = tutorials.map((t) => ({ slug: t.id, title: t.data.title }));
|
|
44
|
+
|
|
45
|
+
// Hit counts per facet. Computed server-side over the already-loaded
|
|
46
|
+
// tutorials list (no extra IO). Passed to FilterBar as plain Records;
|
|
47
|
+
// Maps don't serialize across the SSR/island boundary.
|
|
48
|
+
const difficultyCounts: Record<string, number> = {};
|
|
49
|
+
const tagCounts: Record<string, number> = {};
|
|
50
|
+
for (const t of tutorials) {
|
|
51
|
+
difficultyCounts[t.data.difficulty] = (difficultyCounts[t.data.difficulty] ?? 0) + 1;
|
|
52
|
+
for (const tag of t.data.tags) tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
|
|
53
|
+
}
|
|
44
54
|
---
|
|
45
55
|
<BaseLayout
|
|
46
56
|
siteName={siteName}
|
|
@@ -57,13 +67,21 @@ const compactTutorials = tutorials.map((t) => ({ slug: t.id, title: t.data.title
|
|
|
57
67
|
<ResumeRail client:load tutorials={compactTutorials} />
|
|
58
68
|
)}
|
|
59
69
|
|
|
60
|
-
<FilterBar
|
|
70
|
+
<FilterBar
|
|
71
|
+
client:load
|
|
72
|
+
difficulties={difficulties}
|
|
73
|
+
tags={tags}
|
|
74
|
+
difficultyCounts={difficultyCounts}
|
|
75
|
+
tagCounts={tagCounts}
|
|
76
|
+
/>
|
|
77
|
+
|
|
78
|
+
<div class="results-status" data-results-status aria-live="polite"></div>
|
|
61
79
|
|
|
62
80
|
<section class="grid">
|
|
63
81
|
{tutorials.map((tut) => {
|
|
64
82
|
const steps = stepsByTutorial.get(tut.id) ?? [];
|
|
65
83
|
const dur = tut.data.estimatedDuration ?? sumDurations(steps);
|
|
66
|
-
return <TutorialCard tutorial={tut} duration={dur} />;
|
|
84
|
+
return <TutorialCard tutorial={tut} duration={dur} stepCount={steps.length} />;
|
|
67
85
|
})}
|
|
68
86
|
</section>
|
|
69
87
|
|
|
@@ -88,21 +106,39 @@ const compactTutorials = tutorials.map((t) => ({ slug: t.id, title: t.data.title
|
|
|
88
106
|
import { getStore } from "../lib/progress/local.ts";
|
|
89
107
|
function refresh() {
|
|
90
108
|
const state = getStore().get();
|
|
91
|
-
|
|
109
|
+
// Only the *numerator* (completed-step count) is computed from
|
|
110
|
+
// localStorage. The *denominator* comes from the card's
|
|
111
|
+
// server-rendered `data-step-count` so a learner who's only
|
|
112
|
+
// visited 1 of 4 steps still sees "1/4", not "1/1".
|
|
92
113
|
const completed = new Map<string, number>();
|
|
93
114
|
for (const [key, value] of Object.entries(state.steps)) {
|
|
115
|
+
if (value !== "complete") continue;
|
|
94
116
|
const slug = key.split("/")[0];
|
|
95
117
|
if (!slug) continue;
|
|
96
|
-
|
|
97
|
-
if (value === "complete") completed.set(slug, (completed.get(slug) ?? 0) + 1);
|
|
118
|
+
completed.set(slug, (completed.get(slug) ?? 0) + 1);
|
|
98
119
|
}
|
|
120
|
+
// SVG ring geometry: r=9 → circumference 2π·9 ≈ 56.5485.
|
|
121
|
+
const R = 9;
|
|
122
|
+
const C = 2 * Math.PI * R;
|
|
99
123
|
document.querySelectorAll<HTMLElement>("[data-tutorial-slug]").forEach((el) => {
|
|
100
124
|
const slug = el.dataset.tutorialSlug!;
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
if (total === 0)
|
|
104
|
-
|
|
105
|
-
|
|
125
|
+
const total = Number(el.dataset.stepCount ?? "0");
|
|
126
|
+
const done = Math.min(completed.get(slug) ?? 0, total);
|
|
127
|
+
if (total === 0 || done === 0) {
|
|
128
|
+
el.innerHTML = "";
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const pct = done / total;
|
|
132
|
+
const offset = C * (1 - pct);
|
|
133
|
+
el.setAttribute("title", `${done} of ${total} steps complete`);
|
|
134
|
+
el.innerHTML =
|
|
135
|
+
`<span class="ring-label">${done}/${total}</span>` +
|
|
136
|
+
`<svg class="ring" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">` +
|
|
137
|
+
`<circle class="ring-track" cx="12" cy="12" r="${R}"></circle>` +
|
|
138
|
+
`<circle class="ring-fill" cx="12" cy="12" r="${R}" ` +
|
|
139
|
+
`stroke-dasharray="${C.toFixed(2)}" ` +
|
|
140
|
+
`stroke-dashoffset="${offset.toFixed(2)}"></circle>` +
|
|
141
|
+
`</svg>`;
|
|
106
142
|
});
|
|
107
143
|
}
|
|
108
144
|
refresh();
|
|
@@ -190,6 +226,12 @@ const compactTutorials = tutorials.map((t) => ({ slug: t.id, title: t.data.title
|
|
|
190
226
|
|
|
191
227
|
<style>
|
|
192
228
|
.home {
|
|
229
|
+
/* width: 100% defeats the auto-margin space-absorption that
|
|
230
|
+
* BaseLayout's flex-column body would otherwise apply, so the
|
|
231
|
+
* page actually reaches its max-width at wide viewports. Without
|
|
232
|
+
* this, auto margins eat all the slack and the column collapses
|
|
233
|
+
* to its content width. */
|
|
234
|
+
width: 100%;
|
|
193
235
|
max-width: 80rem;
|
|
194
236
|
margin: 0 auto;
|
|
195
237
|
padding: 0 clamp(1rem, 4vw, 2rem) 4rem;
|
|
@@ -198,8 +240,16 @@ const compactTutorials = tutorials.map((t) => ({ slug: t.id, title: t.data.title
|
|
|
198
240
|
display: grid;
|
|
199
241
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
200
242
|
gap: 1rem;
|
|
201
|
-
margin-top:
|
|
243
|
+
margin-top: 0.75rem;
|
|
244
|
+
}
|
|
245
|
+
.results-status {
|
|
246
|
+
margin-top: 1rem;
|
|
247
|
+
font-family: var(--font-mono);
|
|
248
|
+
font-size: 0.78em;
|
|
249
|
+
color: var(--color-muted);
|
|
250
|
+
min-height: 1em;
|
|
202
251
|
}
|
|
252
|
+
.results-status:empty { display: none; }
|
|
203
253
|
.empty {
|
|
204
254
|
margin: 2rem 0;
|
|
205
255
|
padding: 2rem;
|
|
@@ -252,107 +302,10 @@ const compactTutorials = tutorials.map((t) => ({ slug: t.id, title: t.data.title
|
|
|
252
302
|
}
|
|
253
303
|
.resume-rail:hover .rr-arrow { transform: translateX(3px); }
|
|
254
304
|
|
|
255
|
-
/*
|
|
256
|
-
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
/* Consistent vertical rhythm on the home page:
|
|
260
|
-
* - Every block (resume rail, filterbar, grid) gets 1.5rem above.
|
|
261
|
-
* - Inside the filterbar, the search row → tags row gap is 1rem.
|
|
262
|
-
* The previous design also had padding-bottom + border-bottom on
|
|
263
|
-
* the filterbar, which doubled the spacing below the tags
|
|
264
|
-
* relative to the 1.5rem above the search bar — that visible
|
|
265
|
-
* imbalance is gone now. */
|
|
266
|
-
.filterbar {
|
|
267
|
-
display: flex;
|
|
268
|
-
flex-direction: column;
|
|
269
|
-
gap: 1.5rem;
|
|
270
|
-
margin-top: 1.5rem;
|
|
271
|
-
}
|
|
272
|
-
.fb-row-primary {
|
|
273
|
-
display: flex;
|
|
274
|
-
gap: 0.75rem;
|
|
275
|
-
align-items: stretch;
|
|
276
|
-
flex-wrap: wrap;
|
|
277
|
-
}
|
|
278
|
-
/* Stretch the pills group itself so each pill can flex to row height */
|
|
279
|
-
.fb-row-primary .pills {
|
|
280
|
-
align-items: stretch;
|
|
281
|
-
}
|
|
282
|
-
.filterbar .search {
|
|
283
|
-
flex: 1 1 22rem;
|
|
284
|
-
display: inline-flex;
|
|
285
|
-
align-items: center;
|
|
286
|
-
gap: 0.5rem;
|
|
287
|
-
padding: 0.45rem 0.7rem;
|
|
288
|
-
border: 1px solid var(--color-border);
|
|
289
|
-
color: var(--color-muted);
|
|
290
|
-
transition: border-color 0.12s ease, color 0.12s ease;
|
|
291
|
-
}
|
|
292
|
-
.filterbar .search:focus-within {
|
|
293
|
-
border-color: var(--color-accent);
|
|
294
|
-
color: var(--color-fg);
|
|
295
|
-
}
|
|
296
|
-
.filterbar input {
|
|
297
|
-
background: transparent;
|
|
298
|
-
border: 0;
|
|
299
|
-
color: var(--color-fg);
|
|
300
|
-
font: inherit;
|
|
301
|
-
outline: none;
|
|
302
|
-
width: 100%;
|
|
303
|
-
}
|
|
304
|
-
.pills {
|
|
305
|
-
display: inline-flex;
|
|
306
|
-
flex-wrap: wrap;
|
|
307
|
-
gap: 0.3rem;
|
|
308
|
-
align-items: center;
|
|
309
|
-
}
|
|
310
|
-
.pill {
|
|
311
|
-
padding: 0.3rem 0.65rem;
|
|
312
|
-
border: 1px solid var(--color-border);
|
|
313
|
-
background: transparent;
|
|
314
|
-
color: var(--color-muted);
|
|
315
|
-
font-family: var(--font-mono);
|
|
316
|
-
font-size: 0.75em;
|
|
317
|
-
cursor: pointer;
|
|
318
|
-
text-transform: lowercase;
|
|
319
|
-
transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
|
|
320
|
-
}
|
|
321
|
-
/* Pills in the primary row stretch to match the search input's
|
|
322
|
-
* height so the whole row reads as one strip. align-items on the
|
|
323
|
-
* inner flex re-centers the text within each grown pill. Tag pills
|
|
324
|
-
* stay compact on the secondary row. */
|
|
325
|
-
.fb-row-primary .pill,
|
|
326
|
-
.fb-row-primary .clear {
|
|
327
|
-
padding: 0 0.85rem;
|
|
328
|
-
align-items: center;
|
|
329
|
-
}
|
|
330
|
-
.pill:hover {
|
|
331
|
-
border-color: var(--color-accent);
|
|
332
|
-
color: var(--color-fg);
|
|
333
|
-
}
|
|
334
|
-
.pill.is-active {
|
|
335
|
-
background: var(--color-accent);
|
|
336
|
-
color: var(--color-accent-fg);
|
|
337
|
-
border-color: var(--color-accent);
|
|
338
|
-
}
|
|
339
|
-
.clear {
|
|
340
|
-
display: inline-flex;
|
|
341
|
-
align-items: center;
|
|
342
|
-
gap: 0.3rem;
|
|
343
|
-
padding: 0.3rem 0.6rem;
|
|
344
|
-
background: transparent;
|
|
345
|
-
border: 1px solid var(--color-border);
|
|
346
|
-
color: var(--color-muted);
|
|
347
|
-
font-family: var(--font-mono);
|
|
348
|
-
font-size: 0.75em;
|
|
349
|
-
cursor: pointer;
|
|
350
|
-
transition: color 0.12s ease, border-color 0.12s ease;
|
|
351
|
-
}
|
|
352
|
-
.clear:hover { color: var(--color-fg); border-color: var(--color-fg); }
|
|
353
|
-
|
|
354
|
-
/* Cards hidden by filter and/or pagination. Both layers set their
|
|
355
|
-
* own data-attribute so they compose without stomping each other. */
|
|
305
|
+
/* Filterbar + multi-select popover + pill styles live in
|
|
306
|
+
* packages/core/styles/components/filterbar.css and multiselect.css.
|
|
307
|
+
* Cards hidden by filter and/or pagination — both layers compose
|
|
308
|
+
* via their own data-attribute. */
|
|
356
309
|
[data-filter-hidden],
|
|
357
310
|
[data-page-hidden] {
|
|
358
311
|
display: none !important;
|
|
@@ -388,21 +341,4 @@ const compactTutorials = tutorials.map((t) => ({ slug: t.id, title: t.data.title
|
|
|
388
341
|
}
|
|
389
342
|
.pg-status strong { color: var(--color-fg); }
|
|
390
343
|
|
|
391
|
-
/* ---------- Per-card mini progress bar ---------- */
|
|
392
|
-
[data-tutorial-slug] {
|
|
393
|
-
display: flex;
|
|
394
|
-
align-items: center;
|
|
395
|
-
gap: 0.5rem;
|
|
396
|
-
margin-top: 0.75rem;
|
|
397
|
-
font-family: var(--font-mono);
|
|
398
|
-
font-size: 0.7em;
|
|
399
|
-
color: var(--color-muted);
|
|
400
|
-
}
|
|
401
|
-
.mini-bar {
|
|
402
|
-
flex: 1;
|
|
403
|
-
height: 3px;
|
|
404
|
-
background: var(--color-surface);
|
|
405
|
-
overflow: hidden;
|
|
406
|
-
}
|
|
407
|
-
.mini-bar div { background: var(--color-accent); height: 100%; }
|
|
408
344
|
</style>
|
package/src/pages/paths.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* export const getStaticPaths = getTutorialLandingPaths;
|
|
6
6
|
* export const getStaticPaths = getTutorialStepPaths;
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
import type { StepEntry, TutorialEntry } from "../lib/content.ts";
|
|
10
|
+
import { getStepsForTutorial, getTutorials, parseStepId } from "../lib/content.ts";
|
|
10
11
|
|
|
11
12
|
export async function getTutorialLandingPaths() {
|
|
12
13
|
const tutorials = await getTutorials();
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* GitHub is the only provider in 0.2; email/password and others are
|
|
11
11
|
* out of scope (see plan: github-auth_d52529d5).
|
|
12
12
|
*/
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
import GitHub from "@auth/core/providers/github";
|
|
15
|
+
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
|
15
16
|
import { defineConfig } from "auth-astro";
|
|
16
17
|
import { accounts, sessions, users, verificationTokens } from "./schema.ts";
|
|
17
18
|
|
|
@@ -6,14 +6,7 @@
|
|
|
6
6
|
* The `learners` row that maps a signed-in user to local progress lives
|
|
7
7
|
* in `../db/schema.ts` — it adds a nullable `user_id` FK to `users` here.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
10
|
-
integer,
|
|
11
|
-
pgTable,
|
|
12
|
-
primaryKey,
|
|
13
|
-
text,
|
|
14
|
-
timestamp,
|
|
15
|
-
uuid,
|
|
16
|
-
} from "drizzle-orm/pg-core";
|
|
9
|
+
import { integer, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
17
10
|
|
|
18
11
|
export const users = pgTable("users", {
|
|
19
12
|
id: uuid("id").primaryKey().defaultRandom(),
|
package/src/server/auth.ts
CHANGED
|
@@ -61,11 +61,7 @@ export async function getOrCreateLearner(
|
|
|
61
61
|
// Anonymous path — unchanged from pre-auth behaviour.
|
|
62
62
|
let deviceId = cookies.get(COOKIE)?.value;
|
|
63
63
|
if (deviceId) {
|
|
64
|
-
const found = await db
|
|
65
|
-
.select()
|
|
66
|
-
.from(learners)
|
|
67
|
-
.where(eq(learners.deviceId, deviceId))
|
|
68
|
-
.limit(1);
|
|
64
|
+
const found = await db.select().from(learners).where(eq(learners.deviceId, deviceId)).limit(1);
|
|
69
65
|
if (found[0]) return { id: found[0].id, deviceId };
|
|
70
66
|
}
|
|
71
67
|
deviceId = randomDeviceId();
|
package/src/server/db/schema.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { users } from "../auth/schema.ts";
|
|
|
13
13
|
|
|
14
14
|
// Re-export the Auth.js tables so consumers (and drizzle-kit) see one
|
|
15
15
|
// schema barrel.
|
|
16
|
-
export {
|
|
16
|
+
export { accounts, sessions, users, verificationTokens } from "../auth/schema.ts";
|
|
17
17
|
|
|
18
18
|
export const learners = pgTable(
|
|
19
19
|
"learners",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
-
import { eq, sql } from "drizzle-orm";
|
|
2
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { getOrCreateLearner } from "../auth.ts";
|
|
5
5
|
import { getDb } from "../db/client.ts";
|
|
@@ -61,30 +61,53 @@ export const POST: APIRoute = async ({ cookies, request }) => {
|
|
|
61
61
|
const learner = await getOrCreateLearner(cookies, request);
|
|
62
62
|
const db = getDb();
|
|
63
63
|
const now = new Date();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
64
|
+
|
|
65
|
+
// `value: null` is the tombstone signal for "this entry was undone"
|
|
66
|
+
// (e.g. unchecking a checkpoint). The `value` column is NOT NULL, so
|
|
67
|
+
// we DELETE these rows instead of upserting them.
|
|
68
|
+
const deletes = parsed.filter((b) => b.value === null);
|
|
69
|
+
const upserts = parsed.filter((b) => b.value !== null);
|
|
70
|
+
|
|
71
|
+
for (const d of deletes) {
|
|
72
|
+
await db
|
|
73
|
+
.delete(progressEntries)
|
|
74
|
+
.where(
|
|
75
|
+
and(
|
|
76
|
+
eq(progressEntries.learnerId, learner.id),
|
|
77
|
+
eq(progressEntries.kind, d.kind),
|
|
78
|
+
eq(progressEntries.scope, d.scope),
|
|
79
|
+
eq(progressEntries.key, d.key),
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (upserts.length > 0) {
|
|
85
|
+
const rows = upserts.map((b) => ({
|
|
86
|
+
learnerId: learner.id,
|
|
87
|
+
kind: b.kind,
|
|
88
|
+
scope: b.scope,
|
|
89
|
+
key: b.key,
|
|
90
|
+
value: b.value,
|
|
91
|
+
updatedAt: now,
|
|
92
|
+
}));
|
|
93
|
+
await db
|
|
94
|
+
.insert(progressEntries)
|
|
95
|
+
.values(rows)
|
|
96
|
+
.onConflictDoUpdate({
|
|
97
|
+
target: [
|
|
98
|
+
progressEntries.learnerId,
|
|
99
|
+
progressEntries.kind,
|
|
100
|
+
progressEntries.scope,
|
|
101
|
+
progressEntries.key,
|
|
102
|
+
],
|
|
103
|
+
set: {
|
|
104
|
+
// `excluded` is the row Postgres would have inserted — without
|
|
105
|
+
// this the SET was a no-op (`value = progress_entries.value`).
|
|
106
|
+
value: sql`excluded.value`,
|
|
107
|
+
updatedAt: sql`excluded.updated_at`,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return json({ written: parsed.length });
|
|
90
113
|
};
|
|
@@ -29,9 +29,12 @@ let cache: CacheEntry | null = null;
|
|
|
29
29
|
*/
|
|
30
30
|
export const GET: APIRoute = async () => {
|
|
31
31
|
if (!process.env.DATABASE_URL) {
|
|
32
|
-
return json(
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
return json(
|
|
33
|
+
{ stats: [] satisfies TutorialStat[] },
|
|
34
|
+
{
|
|
35
|
+
headers: { "Cache-Control": "public, max-age=60" },
|
|
36
|
+
},
|
|
37
|
+
);
|
|
35
38
|
}
|
|
36
39
|
const now = Date.now();
|
|
37
40
|
if (cache && cache.expiresAt > now) {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/* Shared dropdown component (handzon-core/src/components/ui/Dropdown.tsx).
|
|
2
|
+
*
|
|
3
|
+
* Wraps Radix Select so we never ship the browser-native dropdown UI.
|
|
4
|
+
* Every dropdown in handzon-core inherits these styles — keep the look
|
|
5
|
+
* mono/brutalist (hard edges, uppercase mono label chip, accent border
|
|
6
|
+
* on focus / open) so dropdowns sit alongside the search input, pills,
|
|
7
|
+
* and pagination buttons without visual drift.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
.hz-dd {
|
|
11
|
+
display: inline-flex;
|
|
12
|
+
align-items: stretch;
|
|
13
|
+
font-family: var(--font-mono);
|
|
14
|
+
font-size: 0.78em;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* Uppercase mono label chip glued to the trigger. */
|
|
18
|
+
.hz-dd-label {
|
|
19
|
+
display: inline-flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
padding: 0 0.6rem;
|
|
22
|
+
border: 1px solid var(--color-border);
|
|
23
|
+
border-right: 0;
|
|
24
|
+
color: var(--color-muted);
|
|
25
|
+
text-transform: uppercase;
|
|
26
|
+
letter-spacing: 0.06em;
|
|
27
|
+
user-select: none;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.hz-dd-trigger {
|
|
31
|
+
display: inline-flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
gap: 0.45rem;
|
|
34
|
+
padding: 0.4rem 0.6rem;
|
|
35
|
+
/* Match the search input's natural height so the whole toolbar row
|
|
36
|
+
* shares a single baseline. Without this, the Sort dropdown reads
|
|
37
|
+
* ~6px shorter than the multi-select triggers which stretch via
|
|
38
|
+
* the toolbar's align-items: stretch. */
|
|
39
|
+
min-height: 2.5rem;
|
|
40
|
+
background: transparent;
|
|
41
|
+
border: 1px solid var(--color-border);
|
|
42
|
+
color: var(--color-fg);
|
|
43
|
+
font: inherit;
|
|
44
|
+
cursor: pointer;
|
|
45
|
+
outline: none;
|
|
46
|
+
transition: border-color 0.12s ease, color 0.12s ease;
|
|
47
|
+
}
|
|
48
|
+
.hz-dd-trigger:hover {
|
|
49
|
+
border-color: var(--color-accent);
|
|
50
|
+
}
|
|
51
|
+
.hz-dd-trigger:focus-visible,
|
|
52
|
+
.hz-dd-trigger[data-state="open"] {
|
|
53
|
+
border-color: var(--color-accent);
|
|
54
|
+
color: var(--color-accent);
|
|
55
|
+
}
|
|
56
|
+
.hz-dd-trigger[data-placeholder] {
|
|
57
|
+
color: var(--color-muted);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.hz-dd-tricon {
|
|
61
|
+
display: inline-flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
color: var(--color-muted);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.hz-dd-caret {
|
|
67
|
+
display: inline-flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
margin-left: 0.15rem;
|
|
70
|
+
color: var(--color-muted);
|
|
71
|
+
transition: transform 0.15s ease;
|
|
72
|
+
}
|
|
73
|
+
.hz-dd-trigger[data-state="open"] .hz-dd-caret {
|
|
74
|
+
transform: rotate(180deg);
|
|
75
|
+
color: var(--color-accent);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ---------- Popover ---------- */
|
|
79
|
+
|
|
80
|
+
.hz-dd-content {
|
|
81
|
+
/* Radix Portal renders this outside the normal flow — float it above
|
|
82
|
+
* the page chrome, including the sticky sidebar (z=10) and the fixed
|
|
83
|
+
* topbar (z=50). */
|
|
84
|
+
z-index: 100;
|
|
85
|
+
min-width: var(--radix-select-trigger-width);
|
|
86
|
+
max-height: var(--radix-select-content-available-height);
|
|
87
|
+
background: var(--color-bg);
|
|
88
|
+
border: 1px solid var(--color-border);
|
|
89
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
|
90
|
+
overflow: hidden;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.hz-dd-viewport {
|
|
94
|
+
padding: 0.25rem 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.hz-dd-item {
|
|
98
|
+
display: flex;
|
|
99
|
+
align-items: center;
|
|
100
|
+
gap: 0.5rem;
|
|
101
|
+
padding: 0.45rem 0.75rem;
|
|
102
|
+
color: var(--color-fg);
|
|
103
|
+
cursor: pointer;
|
|
104
|
+
user-select: none;
|
|
105
|
+
outline: none;
|
|
106
|
+
}
|
|
107
|
+
.hz-dd-item[data-highlighted] {
|
|
108
|
+
background: var(--color-surface);
|
|
109
|
+
color: var(--color-accent);
|
|
110
|
+
}
|
|
111
|
+
.hz-dd-item[data-state="checked"] {
|
|
112
|
+
color: var(--color-accent);
|
|
113
|
+
}
|
|
114
|
+
.hz-dd-item[data-disabled] {
|
|
115
|
+
opacity: 0.4;
|
|
116
|
+
pointer-events: none;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.hz-dd-icon {
|
|
120
|
+
display: inline-flex;
|
|
121
|
+
align-items: center;
|
|
122
|
+
color: var(--color-muted);
|
|
123
|
+
}
|
|
124
|
+
.hz-dd-item[data-highlighted] .hz-dd-icon,
|
|
125
|
+
.hz-dd-item[data-state="checked"] .hz-dd-icon {
|
|
126
|
+
color: var(--color-accent);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.hz-dd-check {
|
|
130
|
+
margin-left: auto;
|
|
131
|
+
display: inline-flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
color: var(--color-accent);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.hz-dd-scroll {
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
justify-content: center;
|
|
140
|
+
height: 1.5rem;
|
|
141
|
+
color: var(--color-muted);
|
|
142
|
+
background: var(--color-bg);
|
|
143
|
+
cursor: default;
|
|
144
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/* Home-page filter bar.
|
|
2
|
+
*
|
|
3
|
+
* One toolbar row with search + Level dropdown + Topics dropdown + a
|
|
4
|
+
* vertical divider + Sort dropdown. Beneath it: optional "popular
|
|
5
|
+
* topics" pill row (top tags by count) and an "active filters" chip
|
|
6
|
+
* row that only appears when at least one filter is set.
|
|
7
|
+
*
|
|
8
|
+
* The dropdowns themselves (their trigger chrome and popover content)
|
|
9
|
+
* are styled in dropdown.css and multiselect.css respectively — this
|
|
10
|
+
* file only handles the toolbar layout + the inline pill + chip rows.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
.filterbar {
|
|
14
|
+
display: flex;
|
|
15
|
+
flex-direction: column;
|
|
16
|
+
gap: 0.75rem;
|
|
17
|
+
margin-top: 1.5rem;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* ---------- Toolbar (search + facets + sort) ---------- */
|
|
21
|
+
|
|
22
|
+
.fb-toolbar {
|
|
23
|
+
display: flex;
|
|
24
|
+
flex-wrap: wrap;
|
|
25
|
+
align-items: stretch;
|
|
26
|
+
gap: 0.75rem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.fb-toolbar .search {
|
|
30
|
+
flex: 1 1 16rem;
|
|
31
|
+
min-width: 12rem;
|
|
32
|
+
display: inline-flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
gap: 0.5rem;
|
|
35
|
+
padding: 0.45rem 0.7rem;
|
|
36
|
+
border: 1px solid var(--color-border);
|
|
37
|
+
color: var(--color-muted);
|
|
38
|
+
transition: border-color 0.12s ease, color 0.12s ease;
|
|
39
|
+
}
|
|
40
|
+
.fb-toolbar .search:focus-within {
|
|
41
|
+
border-color: var(--color-accent);
|
|
42
|
+
color: var(--color-fg);
|
|
43
|
+
}
|
|
44
|
+
.fb-toolbar .search input {
|
|
45
|
+
background: transparent;
|
|
46
|
+
border: 0;
|
|
47
|
+
color: var(--color-fg);
|
|
48
|
+
font: inherit;
|
|
49
|
+
outline: none;
|
|
50
|
+
width: 100%;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Visual separator marking "sort isn't a filter". The sort cluster
|
|
54
|
+
* pushes itself to the right edge so the divider falls in a natural
|
|
55
|
+
* spot. */
|
|
56
|
+
.fb-divider {
|
|
57
|
+
width: 0;
|
|
58
|
+
border-left: 1px solid var(--color-border);
|
|
59
|
+
align-self: stretch;
|
|
60
|
+
margin: 0 0.25rem;
|
|
61
|
+
}
|
|
62
|
+
.fb-sort-slot {
|
|
63
|
+
display: inline-flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
flex-shrink: 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* On narrow viewports the toolbar wraps; sort drops to its own line.
|
|
69
|
+
* Keep the divider visible only when the row hasn't wrapped — when it
|
|
70
|
+
* has, the layout already reads as distinct rows. Cheap heuristic:
|
|
71
|
+
* hide the divider below ~640px viewport. */
|
|
72
|
+
@media (max-width: 640px) {
|
|
73
|
+
.fb-divider { display: none; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* ---------- Active filter chips ---------- */
|
|
77
|
+
|
|
78
|
+
.active-filters {
|
|
79
|
+
display: flex;
|
|
80
|
+
flex-wrap: wrap;
|
|
81
|
+
gap: 0.5rem;
|
|
82
|
+
padding-top: 0.75rem;
|
|
83
|
+
border-top: 1px solid var(--color-border);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.active-filter-chip {
|
|
87
|
+
display: inline-flex;
|
|
88
|
+
align-items: center;
|
|
89
|
+
gap: 0.35rem;
|
|
90
|
+
padding: 0.25rem 0.55rem;
|
|
91
|
+
background: transparent;
|
|
92
|
+
border: 1px solid var(--color-border);
|
|
93
|
+
color: var(--color-muted);
|
|
94
|
+
font-family: var(--font-mono);
|
|
95
|
+
font-size: 0.72em;
|
|
96
|
+
cursor: pointer;
|
|
97
|
+
transition: color 0.12s ease, border-color 0.12s ease;
|
|
98
|
+
}
|
|
99
|
+
.active-filter-chip:hover,
|
|
100
|
+
.active-filter-chip:focus-visible {
|
|
101
|
+
color: var(--color-accent);
|
|
102
|
+
border-color: var(--color-accent);
|
|
103
|
+
outline: none;
|
|
104
|
+
}
|
|
105
|
+
.active-filter-chip .afc-label { text-transform: lowercase; }
|
|
106
|
+
|
|
107
|
+
.active-filter-clear {
|
|
108
|
+
margin-left: 0.25rem;
|
|
109
|
+
padding: 0.25rem 0.55rem;
|
|
110
|
+
background: transparent;
|
|
111
|
+
border: 0;
|
|
112
|
+
color: var(--color-muted);
|
|
113
|
+
font-family: var(--font-mono);
|
|
114
|
+
font-size: 0.72em;
|
|
115
|
+
text-transform: uppercase;
|
|
116
|
+
letter-spacing: 0.06em;
|
|
117
|
+
cursor: pointer;
|
|
118
|
+
text-decoration: underline;
|
|
119
|
+
text-decoration-color: transparent;
|
|
120
|
+
text-underline-offset: 3px;
|
|
121
|
+
transition: color 0.12s ease, text-decoration-color 0.12s ease;
|
|
122
|
+
}
|
|
123
|
+
.active-filter-clear:hover,
|
|
124
|
+
.active-filter-clear:focus-visible {
|
|
125
|
+
color: var(--color-fg);
|
|
126
|
+
text-decoration-color: currentColor;
|
|
127
|
+
outline: none;
|
|
128
|
+
}
|