astryx-dracula 0.4.0 → 0.5.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 +6 -2
- package/shared/auth-chrome.tsx +156 -6
- package/shared/chaptered-doc.tsx +3 -4
- package/shared/chart-hues.ts +0 -5
- package/shared/chart-legend.tsx +2 -3
- package/shared/data-bar.tsx +5 -5
- package/shared/format-money.ts +3 -0
- package/shared/metric-card.tsx +127 -0
- package/shared/scene-hues.ts +0 -8
- package/shared/scene-tile.tsx +3 -11
- package/shared/settings-data.ts +28 -0
- package/shared/settings-rows.tsx +1 -6
- package/shared/stars.tsx +28 -0
- package/templates/ai-chat-landing.tsx +88 -58
- package/templates/ai-chat.tsx +65 -78
- package/templates/centered-hero.tsx +2 -2
- package/templates/dashboard-portfolio.tsx +77 -155
- package/templates/dashboard.tsx +17 -134
- package/templates/detail-page.tsx +14 -18
- package/templates/documentation-design.tsx +66 -519
- package/templates/documentation-technical.tsx +2 -22
- package/templates/editor.tsx +168 -185
- package/templates/file-explorer.tsx +10 -24
- package/templates/ide.tsx +12 -18
- package/templates/incident-console.tsx +48 -46
- package/templates/kanban-board.tsx +36 -85
- package/templates/library.tsx +42 -66
- package/templates/login-card.tsx +33 -126
- package/templates/login-split.tsx +91 -123
- package/templates/login-sso.tsx +12 -33
- package/templates/login.tsx +21 -121
- package/templates/messaging-shell.tsx +162 -168
- package/templates/payment-form.tsx +24 -26
- package/templates/product-detail.tsx +7 -40
- package/templates/product-tour.tsx +38 -60
- package/templates/settings-dialog.tsx +10 -32
- package/templates/settings-sidebar.tsx +44 -77
- package/templates/settings.tsx +208 -121
- package/templates/shell-nav.tsx +20 -75
- package/templates/shell-side-nav.tsx +16 -90
- package/templates/shell-top-nav.tsx +20 -53
- package/templates/side-gallery.tsx +19 -19
- package/templates/table-grouped.tsx +17 -164
- package/templates/table-page-chart.tsx +12 -11
- package/templates/table-page-heatmap-status.tsx +0 -17
- package/templates/table-page-shoe-store-heatmap.tsx +12 -12
- package/templates/table-page.tsx +4 -31
- package/templates/table.tsx +7 -10
- package/templates/tech-report.tsx +58 -72
- package/templates/theme-showcase.tsx +124 -415
|
@@ -269,17 +269,16 @@ const INCIDENTS: Incident[] = [
|
|
|
269
269
|
|
|
270
270
|
const STATUS_ORDER: Status[] = ['investigating', 'mitigated', 'resolved'];
|
|
271
271
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
resolved: 'green',
|
|
272
|
+
// One union: label for group headings + row tokens, color for row tokens.
|
|
273
|
+
// SEVERITY_DOT stays separate — it keys on severity (sev1/2/3), a different
|
|
274
|
+
// axis than status, so merging would conflate two domains in one map.
|
|
275
|
+
const STATUS: Record<
|
|
276
|
+
Status,
|
|
277
|
+
{label: string; tokenColor: 'cyan' | 'yellow' | 'green'}
|
|
278
|
+
> = {
|
|
279
|
+
investigating: {label: 'Investigating', tokenColor: 'cyan'},
|
|
280
|
+
mitigated: {label: 'Mitigated', tokenColor: 'yellow'},
|
|
281
|
+
resolved: {label: 'Resolved', tokenColor: 'green'},
|
|
283
282
|
};
|
|
284
283
|
|
|
285
284
|
const SEVERITY_DOT: Record<Severity, 'error' | 'warning' | 'neutral'> = {
|
|
@@ -288,6 +287,30 @@ const SEVERITY_DOT: Record<Severity, 'error' | 'warning' | 'neutral'> = {
|
|
|
288
287
|
sev3: 'neutral',
|
|
289
288
|
};
|
|
290
289
|
|
|
290
|
+
// SEV badge shared by rows and the inspector: dot + uppercase word travel
|
|
291
|
+
// together (a bare dot leaves SEV1/2/3 as hue alone; StatusDot's label is
|
|
292
|
+
// aria-only on a role="img" span and paints nothing).
|
|
293
|
+
function SeverityBadge({
|
|
294
|
+
severity,
|
|
295
|
+
isPulsing = false,
|
|
296
|
+
}: {
|
|
297
|
+
severity: Severity;
|
|
298
|
+
isPulsing?: boolean;
|
|
299
|
+
}) {
|
|
300
|
+
return (
|
|
301
|
+
<HStack gap={1} vAlign="center">
|
|
302
|
+
<StatusDot
|
|
303
|
+
variant={SEVERITY_DOT[severity]}
|
|
304
|
+
label={severity.toUpperCase()}
|
|
305
|
+
isPulsing={isPulsing}
|
|
306
|
+
/>
|
|
307
|
+
<Text type="supporting" color="secondary">
|
|
308
|
+
{severity.toUpperCase()}
|
|
309
|
+
</Text>
|
|
310
|
+
</HStack>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
291
314
|
const SERVICE_VALUES = [
|
|
292
315
|
{value: 'checkout-api', label: 'checkout-api'},
|
|
293
316
|
{value: 'search-indexer', label: 'search-indexer'},
|
|
@@ -350,7 +373,7 @@ function IncidentRows({
|
|
|
350
373
|
floor. The group heading is the string this sidebar is scanned
|
|
351
374
|
for. Hierarchy comes from the two type roles (principle 6),
|
|
352
375
|
not from dimming the count. */}
|
|
353
|
-
<Text type="label">{
|
|
376
|
+
<Text type="label">{STATUS[group.status].label}</Text>
|
|
354
377
|
<Text type="supporting" hasTabularNumbers>
|
|
355
378
|
{group.items.length}
|
|
356
379
|
</Text>
|
|
@@ -362,27 +385,17 @@ function IncidentRows({
|
|
|
362
385
|
label={incident.title}
|
|
363
386
|
description={`${incident.id} · ${incident.service} · ${incident.impact}`}
|
|
364
387
|
startContent={
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
<HStack gap={1} vAlign="center">
|
|
370
|
-
<StatusDot
|
|
371
|
-
variant={SEVERITY_DOT[incident.severity]}
|
|
372
|
-
label={incident.severity.toUpperCase()}
|
|
373
|
-
isPulsing={incident.status === 'investigating'}
|
|
374
|
-
/>
|
|
375
|
-
<Text type="supporting" color="secondary">
|
|
376
|
-
{incident.severity.toUpperCase()}
|
|
377
|
-
</Text>
|
|
378
|
-
</HStack>
|
|
388
|
+
<SeverityBadge
|
|
389
|
+
severity={incident.severity}
|
|
390
|
+
isPulsing={incident.status === 'investigating'}
|
|
391
|
+
/>
|
|
379
392
|
}
|
|
380
393
|
endContent={
|
|
381
394
|
<HStack gap={3} vAlign="center">
|
|
382
395
|
<Token
|
|
383
396
|
size="sm"
|
|
384
|
-
color={
|
|
385
|
-
label={
|
|
397
|
+
color={STATUS[incident.status].tokenColor}
|
|
398
|
+
label={STATUS[incident.status].label}
|
|
386
399
|
/>
|
|
387
400
|
<Timestamp
|
|
388
401
|
value={incident.startedAt}
|
|
@@ -414,27 +427,23 @@ function IncidentInspector({incident}: {incident: Incident}) {
|
|
|
414
427
|
<VStack gap={4} style={styles.inspector}>
|
|
415
428
|
<VStack gap={2}>
|
|
416
429
|
<HStack gap={1} vAlign="center">
|
|
417
|
-
<
|
|
418
|
-
|
|
419
|
-
label={incident.severity.toUpperCase()}
|
|
430
|
+
<SeverityBadge
|
|
431
|
+
severity={incident.severity}
|
|
420
432
|
isPulsing={incident.status === 'investigating'}
|
|
421
433
|
/>
|
|
422
|
-
<Text type="supporting" color="secondary">
|
|
423
|
-
{incident.severity.toUpperCase()}
|
|
424
|
-
</Text>
|
|
425
434
|
<Text type="supporting" color="secondary">
|
|
426
435
|
{incident.id}
|
|
427
436
|
</Text>
|
|
428
437
|
<Token
|
|
429
438
|
size="sm"
|
|
430
|
-
color={
|
|
431
|
-
label={
|
|
439
|
+
color={STATUS[incident.status].tokenColor}
|
|
440
|
+
label={STATUS[incident.status].label}
|
|
432
441
|
/>
|
|
433
442
|
</HStack>
|
|
434
443
|
<Heading level={2}>{incident.title}</Heading>
|
|
435
444
|
</VStack>
|
|
436
445
|
|
|
437
|
-
<HStack gap={2}>
|
|
446
|
+
<HStack gap={2} wrap="wrap">
|
|
438
447
|
<Button label={nextAction} variant="primary" />
|
|
439
448
|
<Button label="Escalate" variant="secondary" />
|
|
440
449
|
</HStack>
|
|
@@ -535,21 +544,14 @@ export default function IncidentConsole() {
|
|
|
535
544
|
// phone, and the counter beside it then wraps too and detaches into the
|
|
536
545
|
// corner. Stacking keeps the counter on the title's own line at any width
|
|
537
546
|
// the title needs more than one, and costs nothing at desktop.
|
|
538
|
-
const titleGroup =
|
|
539
|
-
<
|
|
547
|
+
const titleGroup = (
|
|
548
|
+
<HStack gap={2} vAlign="center" wrap="wrap">
|
|
540
549
|
<Heading level={1} type="display-2">
|
|
541
550
|
Night watch
|
|
542
551
|
</Heading>
|
|
543
552
|
<Text type="supporting" color="secondary" hasTabularNumbers>
|
|
544
553
|
{openCount} investigating
|
|
545
554
|
</Text>
|
|
546
|
-
</VStack>
|
|
547
|
-
) : (
|
|
548
|
-
<HStack gap={2} vAlign="center">
|
|
549
|
-
<Heading level={1} type="display-2">Night watch</Heading>
|
|
550
|
-
<Text type="supporting" color="secondary" hasTabularNumbers>
|
|
551
|
-
{openCount} investigating
|
|
552
|
-
</Text>
|
|
553
555
|
</HStack>
|
|
554
556
|
);
|
|
555
557
|
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
useEffect,
|
|
7
7
|
useRef,
|
|
8
8
|
useState,
|
|
9
|
-
type CSSProperties,
|
|
10
9
|
type PointerEvent as ReactPointerEvent,
|
|
11
10
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
12
11
|
type ReactNode,
|
|
@@ -253,58 +252,11 @@ const DRAG_THRESHOLD = 5;
|
|
|
253
252
|
|
|
254
253
|
// ============= STYLES =============
|
|
255
254
|
|
|
256
|
-
// Responsive board grid: columns collapse to fewer tracks, then a single
|
|
257
|
-
// stack. The board keeps its own internal scroll as the only scroller;
|
|
258
|
-
// grid children get room to shrink so nothing forces page-level scroll.
|
|
259
|
-
const boardColumnsStyle: CSSProperties = {
|
|
260
|
-
overflowX: 'auto',
|
|
261
|
-
overflowY: 'auto',
|
|
262
|
-
height: '100%',
|
|
263
|
-
padding: 'var(--spacing-6)',
|
|
264
|
-
};
|
|
265
|
-
const columnShellStyle: CSSProperties = {
|
|
266
|
-
minWidth: 0,
|
|
267
|
-
height: '100%',
|
|
268
|
-
};
|
|
269
|
-
// Cards stay static per the Dracula brand (no hover lift); the floating drag
|
|
270
|
-
// clone floats above the board with the high shadow token instead.
|
|
271
|
-
const cardStyle: CSSProperties = {
|
|
272
|
-
cursor: 'grab',
|
|
273
|
-
userSelect: 'none',
|
|
274
|
-
touchAction: 'none',
|
|
275
|
-
};
|
|
276
255
|
// The dragged card is lifted out of flow and follows the pointer. It ignores
|
|
277
256
|
// pointer events so hit-testing reads the columns underneath it. The clone
|
|
278
257
|
// rides above shell chrome (layered 0-3) but stays under overlay surfaces
|
|
279
258
|
// (menus, dialogs layer at 500+ plus the top layer), so an open menu wins.
|
|
280
259
|
const DRAG_CLONE_LAYER = 100;
|
|
281
|
-
const floatingStyle: CSSProperties = {
|
|
282
|
-
position: 'fixed',
|
|
283
|
-
insetBlockStart: 0,
|
|
284
|
-
insetInlineStart: 0,
|
|
285
|
-
pointerEvents: 'none',
|
|
286
|
-
cursor: 'grabbing',
|
|
287
|
-
zIndex: DRAG_CLONE_LAYER,
|
|
288
|
-
};
|
|
289
|
-
// Placeholder marking the landing slot; matches the dragged card's height.
|
|
290
|
-
const ghostStyle = (height: number): CSSProperties => ({
|
|
291
|
-
height,
|
|
292
|
-
borderRadius: 'var(--radius-element)',
|
|
293
|
-
backgroundColor: 'var(--color-background-muted)',
|
|
294
|
-
});
|
|
295
|
-
const toolbarDividerStyle: CSSProperties = {
|
|
296
|
-
alignSelf: 'stretch',
|
|
297
|
-
};
|
|
298
|
-
const columnEmptyStateStyle: CSSProperties = {
|
|
299
|
-
// 12px, not 24. This applies to an EmptyState inside a LayoutContent
|
|
300
|
-
// padding={3} (12px), so 24 rendered as 36px of block air on the empty
|
|
301
|
-
// branch against 12px on the populated one -- a 3x difference between two
|
|
302
|
-
// states of the same column, and the only nested inset in the kit that
|
|
303
|
-
// exceeded its container. The block padding is the CONTAINER's job; this
|
|
304
|
-
// one is the placeholder's room to breathe inside it.
|
|
305
|
-
paddingBlock: 'var(--spacing-3)',
|
|
306
|
-
};
|
|
307
|
-
|
|
308
260
|
// ============= CARD BODY =============
|
|
309
261
|
|
|
310
262
|
// Shared card contents, rendered both in the column list and inside the
|
|
@@ -332,12 +284,7 @@ function BoardCardBody({
|
|
|
332
284
|
<MoreMenu
|
|
333
285
|
label="Work item actions"
|
|
334
286
|
size="sm"
|
|
335
|
-
items={[
|
|
336
|
-
{label: 'Open', onClick: () => {}},
|
|
337
|
-
{label: 'Assign to me', onClick: () => {}},
|
|
338
|
-
{type: 'divider'},
|
|
339
|
-
...moveTargets,
|
|
340
|
-
]}
|
|
287
|
+
items={[{label: 'Open'}, {label: 'Assign to me'}, {type: 'divider'}, ...moveTargets]}
|
|
341
288
|
/>
|
|
342
289
|
</HStack>
|
|
343
290
|
|
|
@@ -389,7 +336,9 @@ function BoardCard({
|
|
|
389
336
|
<Card
|
|
390
337
|
ref={cardRef}
|
|
391
338
|
padding={3}
|
|
392
|
-
|
|
339
|
+
// Cards stay static per the Dracula brand (no hover lift); the floating
|
|
340
|
+
// drag clone floats above the board with the high shadow token instead.
|
|
341
|
+
style={{cursor: 'grab', userSelect: 'none', touchAction: 'none'}}
|
|
393
342
|
tabIndex={0}
|
|
394
343
|
role="button"
|
|
395
344
|
aria-label={`${item.title}. In ${COLUMNS.find(c => c.id === item.column)?.title}. Press left or right arrow to move across columns.`}
|
|
@@ -414,7 +363,7 @@ function BoardColumn({
|
|
|
414
363
|
children: ReactNode;
|
|
415
364
|
}) {
|
|
416
365
|
return (
|
|
417
|
-
<Card variant="muted" padding={0} style={
|
|
366
|
+
<Card variant="muted" padding={0} style={{minWidth: 0, height: '100%'}}>
|
|
418
367
|
<Layout
|
|
419
368
|
height="fill"
|
|
420
369
|
header={
|
|
@@ -455,7 +404,10 @@ function BoardColumn({
|
|
|
455
404
|
{children ?? (
|
|
456
405
|
<EmptyState
|
|
457
406
|
isCompact
|
|
458
|
-
|
|
407
|
+
// 12px, not 24: this EmptyState sits inside a LayoutContent
|
|
408
|
+
// padding={3} (12px), so 24 would render as 36px of block air
|
|
409
|
+
// on the empty branch against 12px on the populated one.
|
|
410
|
+
style={{paddingBlock: 'var(--spacing-3)'}}
|
|
459
411
|
icon={
|
|
460
412
|
<Icon icon={meta.emptyIcon} size="lg" color="secondary" />
|
|
461
413
|
}
|
|
@@ -488,36 +440,22 @@ export default function KanbanBoard() {
|
|
|
488
440
|
);
|
|
489
441
|
const teardownRef = useRef<(() => void) | null>(null);
|
|
490
442
|
|
|
491
|
-
//
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
cb = el => {
|
|
496
|
-
if (el) {
|
|
497
|
-
columnEls.current.set(id, el);
|
|
498
|
-
} else {
|
|
499
|
-
columnEls.current.delete(id);
|
|
500
|
-
}
|
|
501
|
-
};
|
|
502
|
-
columnRefCbs.current.set(id, cb);
|
|
503
|
-
}
|
|
504
|
-
return cb;
|
|
505
|
-
};
|
|
506
|
-
|
|
507
|
-
const getCardRef = (id: string) => {
|
|
508
|
-
let cb = cardRefCbs.current.get(id);
|
|
443
|
+
// One helper for both registries: stable per-key ref callbacks so
|
|
444
|
+
// registering an element never churns across renders.
|
|
445
|
+
function refCallback<K>(cbs: Map<K, (el: HTMLDivElement | null) => void>, els: Map<K, HTMLElement>, key: K) {
|
|
446
|
+
let cb = cbs.get(key);
|
|
509
447
|
if (!cb) {
|
|
510
448
|
cb = el => {
|
|
511
449
|
if (el) {
|
|
512
|
-
|
|
450
|
+
els.set(key, el);
|
|
513
451
|
} else {
|
|
514
|
-
|
|
452
|
+
els.delete(key);
|
|
515
453
|
}
|
|
516
454
|
};
|
|
517
|
-
|
|
455
|
+
cbs.set(key, cb);
|
|
518
456
|
}
|
|
519
457
|
return cb;
|
|
520
|
-
}
|
|
458
|
+
}
|
|
521
459
|
|
|
522
460
|
const itemsByColumn = groupByColumn(items);
|
|
523
461
|
|
|
@@ -688,7 +626,7 @@ export default function KanbanBoard() {
|
|
|
688
626
|
<BoardCard
|
|
689
627
|
key={it.id}
|
|
690
628
|
item={it}
|
|
691
|
-
cardRef={
|
|
629
|
+
cardRef={refCallback(cardRefCbs.current, cardEls.current, it.id)}
|
|
692
630
|
onPointerDown={onCardPointerDown}
|
|
693
631
|
onMove={moveItem}
|
|
694
632
|
/>
|
|
@@ -699,7 +637,15 @@ export default function KanbanBoard() {
|
|
|
699
637
|
nodes.splice(
|
|
700
638
|
index,
|
|
701
639
|
0,
|
|
702
|
-
<VStack
|
|
640
|
+
<VStack
|
|
641
|
+
key="drag-ghost"
|
|
642
|
+
// Placeholder marking the landing slot; matches the dragged card's height.
|
|
643
|
+
style={{
|
|
644
|
+
height: ghostTarget.height,
|
|
645
|
+
borderRadius: 'var(--radius-element)',
|
|
646
|
+
backgroundColor: 'var(--color-background-muted)',
|
|
647
|
+
}}
|
|
648
|
+
/>,
|
|
703
649
|
);
|
|
704
650
|
}
|
|
705
651
|
|
|
@@ -739,7 +685,7 @@ export default function KanbanBoard() {
|
|
|
739
685
|
<Divider
|
|
740
686
|
variant="strong"
|
|
741
687
|
orientation="vertical"
|
|
742
|
-
style={
|
|
688
|
+
style={{alignSelf: 'stretch'}}
|
|
743
689
|
/>
|
|
744
690
|
<HStack gap={1} vAlign="center" wrap="wrap">
|
|
745
691
|
<IconButton
|
|
@@ -770,7 +716,7 @@ export default function KanbanBoard() {
|
|
|
770
716
|
<Grid
|
|
771
717
|
columns={{ minWidth: 280, max: 4 }}
|
|
772
718
|
gap={4}
|
|
773
|
-
style={
|
|
719
|
+
style={{overflowX: 'auto', overflowY: 'auto', height: '100%', padding: 'var(--spacing-6)'}}
|
|
774
720
|
tabIndex={0}
|
|
775
721
|
role="region"
|
|
776
722
|
aria-label="Sprint board columns">
|
|
@@ -779,7 +725,7 @@ export default function KanbanBoard() {
|
|
|
779
725
|
key={meta.id}
|
|
780
726
|
meta={meta}
|
|
781
727
|
count={itemsByColumn[meta.id].length}
|
|
782
|
-
contentRef={
|
|
728
|
+
contentRef={refCallback(columnRefCbs.current, columnEls.current, meta.id)}>
|
|
783
729
|
{renderColumnCards(meta.id)}
|
|
784
730
|
</BoardColumn>
|
|
785
731
|
))}
|
|
@@ -792,7 +738,12 @@ export default function KanbanBoard() {
|
|
|
792
738
|
padding={3}
|
|
793
739
|
elevation="none"
|
|
794
740
|
style={{
|
|
795
|
-
|
|
741
|
+
position: 'fixed',
|
|
742
|
+
insetBlockStart: 0,
|
|
743
|
+
insetInlineStart: 0,
|
|
744
|
+
pointerEvents: 'none',
|
|
745
|
+
cursor: 'grabbing',
|
|
746
|
+
zIndex: DRAG_CLONE_LAYER,
|
|
796
747
|
width: drag.width,
|
|
797
748
|
borderColor: 'var(--color-border-emphasized)',
|
|
798
749
|
transform: `translate(${drag.pointerX - drag.offsetX}px, ${drag.pointerY - drag.offsetY}px)`,
|
package/templates/library.tsx
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Library — a browsable grid of design-system entries grouped by category. Frame: page header (title) | content column (search, filter row, s (Frame/responsive/container: see XLE header above.)
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {useState
|
|
9
|
+
import {useState} from 'react';
|
|
10
10
|
import {Layout, LayoutHeader, LayoutContent} from '@astryxdesign/core/Layout';
|
|
11
11
|
import {Text, Heading} from '@astryxdesign/core/Text';
|
|
12
12
|
import {Card} from '@astryxdesign/core/Card';
|
|
@@ -301,80 +301,48 @@ function LibraryCard({item}: {item: LibraryItem}) {
|
|
|
301
301
|
);
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
-
function LibrarySection({
|
|
305
|
-
category,
|
|
306
|
-
items,
|
|
307
|
-
}: {
|
|
308
|
-
category: string;
|
|
309
|
-
items: LibraryItem[];
|
|
310
|
-
}) {
|
|
311
|
-
return (
|
|
312
|
-
<VStack gap={8}>
|
|
313
|
-
<HStack justify="between" vAlign="center">
|
|
314
|
-
<Heading level={2}>{category}</Heading>
|
|
315
|
-
<Text type="body" color="secondary" hasTabularNumbers>
|
|
316
|
-
{items.length} {items.length === 1 ? 'item' : 'items'}
|
|
317
|
-
</Text>
|
|
318
|
-
</HStack>
|
|
319
|
-
<Grid columns={{minWidth: 280}} gap={4}>
|
|
320
|
-
{items.map(item => (
|
|
321
|
-
<LibraryCard key={item.id} item={item} />
|
|
322
|
-
))}
|
|
323
|
-
</Grid>
|
|
324
|
-
</VStack>
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
304
|
export default function LibraryGrid() {
|
|
329
305
|
const [activeTab, setActiveTab] = useState('All');
|
|
330
306
|
const [search, setSearch] = useState('');
|
|
331
307
|
const [sortOrder, setSortOrder] = useState('A-Z');
|
|
332
308
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
309
|
+
// ITEMS is module-static (only .filter over it and .sort over copies below),
|
|
310
|
+
// so plain consts recompute cheaply per render with no memo needed.
|
|
311
|
+
const query = search.trim().toLowerCase();
|
|
312
|
+
const visible =
|
|
313
|
+
activeTab === 'All' ? ITEMS : ITEMS.filter(i => i.category === activeTab);
|
|
314
|
+
const searched = query
|
|
315
|
+
? visible.filter(
|
|
339
316
|
i =>
|
|
340
|
-
i.name.toLowerCase().includes(
|
|
341
|
-
i.description.toLowerCase().includes(
|
|
342
|
-
)
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
return sorted;
|
|
353
|
-
}, [activeTab, search, sortOrder]);
|
|
317
|
+
i.name.toLowerCase().includes(query) ||
|
|
318
|
+
i.description.toLowerCase().includes(query),
|
|
319
|
+
)
|
|
320
|
+
: visible;
|
|
321
|
+
const filtered = [...searched];
|
|
322
|
+
if (sortOrder === 'A-Z') {
|
|
323
|
+
filtered.sort((a, b) => a.name.localeCompare(b.name));
|
|
324
|
+
} else if (sortOrder === 'Z-A') {
|
|
325
|
+
filtered.sort((a, b) => b.name.localeCompare(a.name));
|
|
326
|
+
} else if (sortOrder === 'Newest') {
|
|
327
|
+
filtered.sort((a, b) => Number(b.id) - Number(a.id));
|
|
328
|
+
}
|
|
354
329
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
330
|
+
let groupedSections: Array<{category: string; items: LibraryItem[]}> | null =
|
|
331
|
+
null;
|
|
332
|
+
if (activeTab === 'All') {
|
|
359
333
|
const order = CATEGORIES.filter(c => c !== 'All');
|
|
360
|
-
const
|
|
334
|
+
const byCategory: Record<string, LibraryItem[]> = {};
|
|
361
335
|
for (const item of filtered) {
|
|
362
|
-
|
|
363
|
-
if (!group) {
|
|
364
|
-
group = [];
|
|
365
|
-
map.set(item.category, group);
|
|
366
|
-
}
|
|
367
|
-
group.push(item);
|
|
336
|
+
(byCategory[item.category] ??= []).push(item);
|
|
368
337
|
}
|
|
369
|
-
|
|
338
|
+
groupedSections = [];
|
|
370
339
|
for (const cat of order) {
|
|
371
|
-
const items =
|
|
340
|
+
const items = byCategory[cat];
|
|
372
341
|
if (items) {
|
|
373
|
-
|
|
342
|
+
groupedSections.push({category: cat, items});
|
|
374
343
|
}
|
|
375
344
|
}
|
|
376
|
-
|
|
377
|
-
}, [activeTab, filtered]);
|
|
345
|
+
}
|
|
378
346
|
|
|
379
347
|
return (
|
|
380
348
|
<Layout
|
|
@@ -470,11 +438,19 @@ export default function LibraryGrid() {
|
|
|
470
438
|
groupedSections ?? [{category: activeTab, items: filtered}]
|
|
471
439
|
).flatMap(section => [
|
|
472
440
|
<Divider key={`d-${section.category}`} />,
|
|
473
|
-
<
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
441
|
+
<VStack key={section.category} gap={8}>
|
|
442
|
+
<HStack justify="between" vAlign="center">
|
|
443
|
+
<Heading level={2}>{section.category}</Heading>
|
|
444
|
+
<Text type="body" color="secondary" hasTabularNumbers>
|
|
445
|
+
{section.items.length} {section.items.length === 1 ? 'item' : 'items'}
|
|
446
|
+
</Text>
|
|
447
|
+
</HStack>
|
|
448
|
+
<Grid columns={{minWidth: 280}} gap={4}>
|
|
449
|
+
{section.items.map(item => (
|
|
450
|
+
<LibraryCard key={item.id} item={item} />
|
|
451
|
+
))}
|
|
452
|
+
</Grid>
|
|
453
|
+
</VStack>,
|
|
478
454
|
])}
|
|
479
455
|
</VStack>
|
|
480
456
|
)}
|