astryx-dracula 0.1.3 → 0.2.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.
Files changed (94) hide show
  1. package/BRAND.md +1 -1
  2. package/README.md +2 -0
  3. package/USAGE.md +13 -0
  4. package/astryx-dracula.js +4 -4
  5. package/astryx.integration.mjs +3 -0
  6. package/package.json +4 -2
  7. package/templates/ai-chat-landing.template.mjs +6 -0
  8. package/templates/ai-chat-landing.tsx +476 -0
  9. package/templates/ai-chat.template.mjs +6 -0
  10. package/templates/ai-chat.tsx +762 -0
  11. package/templates/blank.template.mjs +6 -0
  12. package/templates/blank.tsx +16 -0
  13. package/templates/centered-hero.template.mjs +6 -0
  14. package/templates/centered-hero.tsx +188 -0
  15. package/templates/classic-gallery.template.mjs +6 -0
  16. package/templates/classic-gallery.tsx +181 -0
  17. package/templates/contact-form.template.mjs +6 -0
  18. package/templates/contact-form.tsx +292 -0
  19. package/templates/dashboard-portfolio.template.mjs +6 -0
  20. package/templates/dashboard-portfolio.tsx +800 -0
  21. package/templates/dashboard.template.mjs +6 -0
  22. package/templates/dashboard.tsx +774 -0
  23. package/templates/detail-page.template.mjs +6 -0
  24. package/templates/detail-page.tsx +695 -0
  25. package/templates/documentation-design.template.mjs +6 -0
  26. package/templates/documentation-design.tsx +751 -0
  27. package/templates/documentation-technical.template.mjs +6 -0
  28. package/templates/documentation-technical.tsx +292 -0
  29. package/templates/documentation.template.mjs +6 -0
  30. package/templates/documentation.tsx +347 -0
  31. package/templates/editor.template.mjs +6 -0
  32. package/templates/editor.tsx +959 -0
  33. package/templates/file-explorer.template.mjs +6 -0
  34. package/templates/file-explorer.tsx +577 -0
  35. package/templates/form-two-column.template.mjs +6 -0
  36. package/templates/form-two-column.tsx +314 -0
  37. package/templates/gallery-hero.template.mjs +6 -0
  38. package/templates/gallery-hero.tsx +287 -0
  39. package/templates/ide.template.mjs +6 -0
  40. package/templates/ide.tsx +533 -0
  41. package/templates/incident-console.template.mjs +6 -0
  42. package/templates/incident-console.tsx +584 -0
  43. package/templates/kanban-board.template.mjs +6 -0
  44. package/templates/kanban-board.tsx +746 -0
  45. package/templates/library.template.mjs +6 -0
  46. package/templates/library.tsx +489 -0
  47. package/templates/login-card.template.mjs +6 -0
  48. package/templates/login-card.tsx +200 -0
  49. package/templates/login-split.template.mjs +6 -0
  50. package/templates/login-split.tsx +318 -0
  51. package/templates/login-sso.template.mjs +6 -0
  52. package/templates/login-sso.tsx +277 -0
  53. package/templates/login.template.mjs +6 -0
  54. package/templates/login.tsx +98 -0
  55. package/templates/messaging-shell.template.mjs +6 -0
  56. package/templates/messaging-shell.tsx +675 -0
  57. package/templates/mixed-gallery.template.mjs +6 -0
  58. package/templates/mixed-gallery.tsx +190 -0
  59. package/templates/payment-form.template.mjs +6 -0
  60. package/templates/payment-form.tsx +990 -0
  61. package/templates/product-detail.template.mjs +6 -0
  62. package/templates/product-detail.tsx +366 -0
  63. package/templates/product-gallery.template.mjs +6 -0
  64. package/templates/product-gallery.tsx +173 -0
  65. package/templates/settings-dialog.template.mjs +6 -0
  66. package/templates/settings-dialog.tsx +920 -0
  67. package/templates/settings-sidebar.template.mjs +6 -0
  68. package/templates/settings-sidebar.tsx +952 -0
  69. package/templates/settings.template.mjs +6 -0
  70. package/templates/settings.tsx +235 -0
  71. package/templates/shell-nav.template.mjs +6 -0
  72. package/templates/shell-nav.tsx +306 -0
  73. package/templates/shell-side-nav.template.mjs +6 -0
  74. package/templates/shell-side-nav.tsx +238 -0
  75. package/templates/shell-top-nav.template.mjs +6 -0
  76. package/templates/shell-top-nav.tsx +243 -0
  77. package/templates/side-gallery.template.mjs +6 -0
  78. package/templates/side-gallery.tsx +179 -0
  79. package/templates/table-grouped.template.mjs +6 -0
  80. package/templates/table-grouped.tsx +1174 -0
  81. package/templates/table-page-chart.template.mjs +6 -0
  82. package/templates/table-page-chart.tsx +693 -0
  83. package/templates/table-page-heatmap-status.template.mjs +6 -0
  84. package/templates/table-page-heatmap-status.tsx +547 -0
  85. package/templates/table-page-shoe-store-heatmap.template.mjs +6 -0
  86. package/templates/table-page-shoe-store-heatmap.tsx +1038 -0
  87. package/templates/table-page.template.mjs +6 -0
  88. package/templates/table-page.tsx +445 -0
  89. package/templates/table.template.mjs +6 -0
  90. package/templates/table.tsx +84 -0
  91. package/templates/theme-showcase.template.mjs +6 -0
  92. package/templates/theme-showcase.tsx +1282 -0
  93. package/theme.css +4 -4
  94. package/tokens.css +4 -4
@@ -0,0 +1,800 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {useState} from 'react';
4
+
5
+ import {VStack, HStack, Layout, LayoutContent} from '@astryxdesign/core/Layout';
6
+ import {Text, Heading} from '@astryxdesign/core/Text';
7
+ import {Card} from '@astryxdesign/core/Card';
8
+ import {Grid, GridSpan} from '@astryxdesign/core/Grid';
9
+ import {Icon} from '@astryxdesign/core/Icon';
10
+ import {Link} from '@astryxdesign/core/Link';
11
+ import {Avatar} from '@astryxdesign/core/Avatar';
12
+ import {List, ListItem} from '@astryxdesign/core/List';
13
+ import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
14
+ import {Badge} from '@astryxdesign/core/Badge';
15
+ import {Button} from '@astryxdesign/core/Button';
16
+ import {Table, proportional} from '@astryxdesign/core/Table';
17
+ import type {TableColumn} from '@astryxdesign/core/Table';
18
+ import {Divider} from '@astryxdesign/core/Divider';
19
+ import {ArrowUp, ArrowDown} from 'lucide-react';
20
+
21
+ // ============= DATA =============
22
+
23
+ // Portfolio value over ~12 months (Oct 2024 → Oct 2025), one data point per day.
24
+ // Realistic fluctuations: dips in Feb–Mar, recovery in summer, climb into fall.
25
+ const portfolioData = (() => {
26
+ const anchors: Array<[number, number]> = [
27
+ [0, 230000],
28
+ [14, 238000],
29
+ [28, 245000],
30
+ [42, 250000],
31
+ [56, 245000],
32
+ [70, 258000],
33
+ [84, 252000],
34
+ [98, 260000],
35
+ [112, 255000],
36
+ [126, 245000],
37
+ [140, 222000],
38
+ [154, 218000],
39
+ [168, 225000],
40
+ [182, 232000],
41
+ [196, 225000],
42
+ [210, 235000],
43
+ [224, 240000],
44
+ [238, 245000],
45
+ [252, 235000],
46
+ [266, 248000],
47
+ [280, 255000],
48
+ [294, 260000],
49
+ [308, 268000],
50
+ [322, 275000],
51
+ [336, 278000],
52
+ [350, 285000],
53
+ [364, 288000],
54
+ [378, 290000],
55
+ [392, 292000],
56
+ [406, 294200],
57
+ ];
58
+ const totalDays = anchors[anchors.length - 1][0];
59
+ const monthPerDay = 12 / totalDays;
60
+ const out: Array<{month: number; label: string; value: number}> = [];
61
+ let ai = 0;
62
+ for (let day = 0; day <= totalDays; day++) {
63
+ while (ai < anchors.length - 2 && day >= anchors[ai + 1][0]) {
64
+ ai++;
65
+ }
66
+ const [d0, v0] = anchors[ai];
67
+ const [d1, v1] = anchors[ai + 1];
68
+ const t = (day - d0) / (d1 - d0);
69
+ const base = v0 + (v1 - v0) * t;
70
+ const seed = Math.sin(day * 12.9898 + 78.233) * 43758.5453;
71
+ const noise = (seed - Math.floor(seed) - 0.5) * 3600;
72
+ out.push({
73
+ month: day * monthPerDay,
74
+ label: `Day ${day + 1}`,
75
+ value: Math.round(base + noise),
76
+ });
77
+ }
78
+ return out;
79
+ })();
80
+
81
+ const xAxisTicks = [0, 3, 6, 9, 12];
82
+ const xAxisLabels: Record<number, string> = {
83
+ 0: 'Oct',
84
+ 3: 'Jan',
85
+ 6: 'Apr',
86
+ 9: 'Jul',
87
+ 12: 'Oct',
88
+ };
89
+
90
+ // KPI summary metrics
91
+ const metrics = [
92
+ {
93
+ value: '$294,200',
94
+ change: '+14.8%',
95
+ label: 'Total value',
96
+ caption: 'Weekly closes · trailing 12 months',
97
+ },
98
+ {
99
+ value: '14.8%',
100
+ change: '+2.1%',
101
+ label: 'Annual return',
102
+ caption: 'Trailing 12 months under moonlight',
103
+ },
104
+ {
105
+ value: '2.8%',
106
+ change: '$2,060/qtr',
107
+ label: 'Dividend yield',
108
+ caption: 'Paid quarterly under moonlight',
109
+ },
110
+ {
111
+ value: '23',
112
+ change: '+4 YTD',
113
+ label: 'Total asset holdings',
114
+ caption: 'Across the night vault',
115
+ },
116
+ ];
117
+
118
+ // Top holdings
119
+ const topAssets = [
120
+ {ticker: 'AAPL', name: 'Apple Inc.', value: '$87,200', change: '+18.4%'},
121
+ {ticker: 'MSFT', name: 'Microsoft Corp.', value: '$72,500', change: '+14.7%'},
122
+ {ticker: 'NVDA', name: 'NVIDIA Corp.', value: '$63,800', change: '+31.2%'},
123
+ {
124
+ ticker: 'VTI',
125
+ name: 'Vanguard Total Stock',
126
+ value: '$58,400',
127
+ change: '+11.3%',
128
+ },
129
+ {
130
+ ticker: 'BND',
131
+ name: 'Vanguard Total Bond',
132
+ value: '$45,600',
133
+ change: '+4.2%',
134
+ },
135
+ ];
136
+
137
+ // 96 points per series = one tick every 15 minutes across a 24h window.
138
+ // Deterministic LCG so the sparklines are stable across renders.
139
+ function genSpark(
140
+ seed: number,
141
+ start: number,
142
+ end: number,
143
+ volatility: number,
144
+ N: number = 96,
145
+ ): number[] {
146
+ let s = seed >>> 0;
147
+ const rand = () => {
148
+ s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
149
+ return s / 0x100000000;
150
+ };
151
+ const points: number[] = [];
152
+ let drift = 0;
153
+ for (let i = 0; i < N; i++) {
154
+ const t = i / (N - 1);
155
+ const trend = start + (end - start) * t;
156
+ // Multiple overlaid waves at different frequencies for session-like rhythm
157
+ // plus high-frequency chop.
158
+ const wave =
159
+ Math.sin(t * Math.PI * 2.3 + seed * 0.13) * volatility * 1.4 +
160
+ Math.sin(t * Math.PI * 5.7 + seed * 0.41) * volatility * 0.9 +
161
+ Math.sin(t * Math.PI * 13.1 + seed * 0.07) * volatility * 0.5;
162
+ // Loosely-correlated random walk so adjacent ticks jitter rather than glide.
163
+ drift = drift * 0.55 + (rand() - 0.5) * volatility * 2.2;
164
+ // Occasional sharper spike to mimic news-driven moves.
165
+ const spike = rand() < 0.04 ? (rand() - 0.5) * volatility * 4 : 0;
166
+ points.push(trend + wave + drift + spike);
167
+ }
168
+ return points;
169
+ }
170
+
171
+ // Market index cards — 24h sparkline data (every 15min, 96 points)
172
+ const marketIndices = [
173
+ {
174
+ name: 'Dow Jones',
175
+ ticker: 'DJI',
176
+ price: '43,821.67',
177
+ change: '+0.42%',
178
+ positive: true,
179
+ spark: genSpark(1337, 78, 84, 3.2),
180
+ },
181
+ {
182
+ name: 'NASDAQ',
183
+ ticker: 'IXIC',
184
+ price: '18,942.18',
185
+ change: '-0.50%',
186
+ positive: false,
187
+ spark: genSpark(2042, 86, 78, 3.8),
188
+ },
189
+ {
190
+ name: 'S&P 500',
191
+ ticker: 'SPX',
192
+ price: '5,918.33',
193
+ change: '+0.21%',
194
+ positive: true,
195
+ spark: genSpark(3155, 71, 75, 2.8),
196
+ },
197
+ {
198
+ name: 'NYSE Composite',
199
+ ticker: 'NYA',
200
+ price: '19,752.41',
201
+ change: '-0.20%',
202
+ positive: false,
203
+ spark: genSpark(4287, 70, 65, 3.0),
204
+ },
205
+ {
206
+ name: 'NVIDIA Corp.',
207
+ ticker: 'NVDA',
208
+ price: '$177.39',
209
+ change: '+0.93%',
210
+ positive: true,
211
+ spark: genSpark(5519, 60, 67, 3.6),
212
+ },
213
+ {
214
+ name: 'Intel Corp.',
215
+ ticker: 'INTC',
216
+ price: '$50.38',
217
+ change: '+4.89%',
218
+ positive: true,
219
+ spark: genSpark(6701, 40, 51, 2.6),
220
+ },
221
+ {
222
+ name: 'Nokia Corp.',
223
+ ticker: 'NOK',
224
+ price: '$8.82',
225
+ change: '+6.65%',
226
+ positive: true,
227
+ spark: genSpark(7823, 30, 41, 2.4),
228
+ },
229
+ {
230
+ name: 'Tesla, Inc.',
231
+ ticker: 'TSLA',
232
+ price: '$360.59',
233
+ change: '-5.42%',
234
+ positive: false,
235
+ spark: genSpark(8945, 90, 76, 4.2),
236
+ },
237
+ ];
238
+
239
+ // Trending stocks table data
240
+ interface StockRow extends Record<string, unknown> {
241
+ id: string;
242
+ ticker: string;
243
+ price: string;
244
+ dailyPts: number;
245
+ dailyPct: number;
246
+ weekChg: number;
247
+ spark: number[];
248
+ }
249
+
250
+ const trendingStocks: StockRow[] = [
251
+ {
252
+ id: '1',
253
+ ticker: 'AAPL',
254
+ price: '$188.72',
255
+ dailyPts: 1.35,
256
+ dailyPct: 0.72,
257
+ weekChg: 22.4,
258
+ spark: genSpark(11023, 60, 70, 2.6, 48),
259
+ },
260
+ {
261
+ id: '2',
262
+ ticker: 'MSFT',
263
+ price: '$415.6',
264
+ dailyPts: 3.2,
265
+ dailyPct: 0.78,
266
+ weekChg: 18.6,
267
+ spark: genSpark(12591, 55, 65, 2.4, 48),
268
+ },
269
+ {
270
+ id: '3',
271
+ ticker: 'NVDA',
272
+ price: '$177.39',
273
+ dailyPts: 1.65,
274
+ dailyPct: 0.93,
275
+ weekChg: 45.2,
276
+ spark: genSpark(13744, 40, 68, 3.8, 48),
277
+ },
278
+ {
279
+ id: '4',
280
+ ticker: 'AMZN',
281
+ price: '$186.5',
282
+ dailyPts: -0.8,
283
+ dailyPct: -0.43,
284
+ weekChg: 15.3,
285
+ spark: genSpark(14882, 70, 63, 2.5, 48),
286
+ },
287
+ {
288
+ id: '5',
289
+ ticker: 'GOOGL',
290
+ price: '$155.72',
291
+ dailyPts: 2.1,
292
+ dailyPct: 1.37,
293
+ weekChg: 12.8,
294
+ spark: genSpark(15967, 50, 60, 2.7, 48),
295
+ },
296
+ {
297
+ id: '6',
298
+ ticker: 'META',
299
+ price: '$505.3',
300
+ dailyPts: 4.5,
301
+ dailyPct: 0.9,
302
+ weekChg: 35.1,
303
+ spark: genSpark(17105, 45, 61, 3.0, 48),
304
+ },
305
+ {
306
+ id: '7',
307
+ ticker: 'TSLA',
308
+ price: '$360.59',
309
+ dailyPts: -20.67,
310
+ dailyPct: -5.42,
311
+ weekChg: -8.3,
312
+ spark: genSpark(18249, 90, 64, 4.4, 48),
313
+ },
314
+ {
315
+ id: '8',
316
+ ticker: 'INTC',
317
+ price: '$50.38',
318
+ dailyPts: 2.35,
319
+ dailyPct: 4.89,
320
+ weekChg: -12.5,
321
+ spark: genSpark(19388, 65, 57, 2.8, 48),
322
+ },
323
+ {
324
+ id: '9',
325
+ ticker: 'AMD',
326
+ price: '$162.45',
327
+ dailyPts: -1.2,
328
+ dailyPct: -0.73,
329
+ weekChg: 28.7,
330
+ spark: genSpark(20471, 50, 63, 2.9, 48),
331
+ },
332
+ {
333
+ id: '10',
334
+ ticker: 'NFLX',
335
+ price: '$628.9',
336
+ dailyPts: 5.4,
337
+ dailyPct: 0.87,
338
+ weekChg: 42.1,
339
+ spark: genSpark(21556, 42, 58, 2.6, 48),
340
+ },
341
+ ];
342
+
343
+ // ============= CHART COMPONENTS =============
344
+
345
+ function PortfolioChart() {
346
+ // Downsample the 407 daily points to one bar per week
347
+ const bars = portfolioData.filter((_, i) => i % 7 === 0);
348
+ const min = 200000;
349
+ const max = 320000;
350
+ const base = 168;
351
+ const plot = 138;
352
+ const left = 52;
353
+ const width = 500;
354
+ const step = width / bars.length;
355
+ const yFor = (v: number) => base - ((v - min) / (max - min)) * plot;
356
+ const yTicks = [200000, 240000, 280000, 320000];
357
+ return (
358
+ <VStack gap={3}>
359
+ <Card
360
+ padding={3}
361
+ style={{
362
+ backgroundColor: 'var(--color-background)',
363
+ border: 'var(--border-width) solid var(--color-separator)',
364
+ }}>
365
+ <svg
366
+ viewBox="0 0 560 210"
367
+ width="100%"
368
+ role="img"
369
+ aria-label="Vault value by week, October to October">
370
+ {yTicks.map(t => (
371
+ <g key={t}>
372
+ <line
373
+ x1={left}
374
+ y1={yFor(t)}
375
+ x2={left + width}
376
+ y2={yFor(t)}
377
+ stroke="var(--color-separator)"
378
+ strokeDasharray="3 3"
379
+ opacity={0.5}
380
+ />
381
+ <text
382
+ x={left - 8}
383
+ y={yFor(t) + 4}
384
+ textAnchor="end"
385
+ fontSize={13}
386
+ fill="var(--color-text-paragraph)"
387
+ fontFamily="var(--font-family-mono)">
388
+ ${(t / 1000).toFixed(0)}k
389
+ </text>
390
+ </g>
391
+ ))}
392
+ {bars.map((d, i) => {
393
+ const h = Math.max(3, base - yFor(d.value));
394
+ return (
395
+ <rect
396
+ key={i}
397
+ x={left + i * step + 1}
398
+ y={base - h}
399
+ width={Math.max(2, step - 2)}
400
+ height={h}
401
+ rx={4}
402
+ fill="var(--dracula-green)"
403
+ />
404
+ );
405
+ })}
406
+ {xAxisTicks.map(m => (
407
+ <text
408
+ key={m}
409
+ x={left + (m / 12) * width}
410
+ y={192}
411
+ textAnchor={m === 12 ? 'end' : 'middle'}
412
+ fontSize={13}
413
+ fill="var(--color-text-paragraph)"
414
+ fontFamily="var(--font-family-mono)">
415
+ {xAxisLabels[m] ?? ''}
416
+ </text>
417
+ ))}
418
+ <text
419
+ x={left + width - 4}
420
+ y={yFor(294200) - 8}
421
+ textAnchor="end"
422
+ fontSize={13}
423
+ fill="var(--color-text-highlight)"
424
+ fontFamily="var(--font-family-mono)">
425
+ $294k
426
+ </text>
427
+ </svg>
428
+ </Card>
429
+ <Text type="supporting" color="secondary">
430
+ Weekly closes · trailing 12 months
431
+ </Text>
432
+ </VStack>
433
+ );
434
+ }
435
+
436
+ // ============= CARD COMPONENTS =============
437
+
438
+ function Sparkline({data, positive}: {data: number[]; positive: boolean}) {
439
+ const max = Math.max(...data);
440
+ const min = Math.min(...data);
441
+ const range = Math.max(1, max - min);
442
+ const step = 300 / data.length;
443
+ return (
444
+ <svg
445
+ viewBox="0 0 300 40"
446
+ width="100%"
447
+ height={40}
448
+ role="img"
449
+ aria-label="Twenty-four hour trend">
450
+ {data.map((v, i) => {
451
+ const h = Math.max(3, ((v - min) / range) * 32);
452
+ return (
453
+ <rect
454
+ key={i}
455
+ x={i * step}
456
+ y={36 - h}
457
+ width={Math.max(1.5, step - 1)}
458
+ height={h}
459
+ rx={4}
460
+ fill={
461
+ positive ? 'var(--dracula-green)' : 'var(--dracula-red)'
462
+ }
463
+ />
464
+ );
465
+ })}
466
+ </svg>
467
+ );
468
+ }
469
+
470
+ function MarketCard({
471
+ name,
472
+ ticker,
473
+ price,
474
+ change,
475
+ positive,
476
+ spark,
477
+ }: {
478
+ name: string;
479
+ ticker: string;
480
+ price: string;
481
+ change: string;
482
+ positive: boolean;
483
+ spark: number[];
484
+ }) {
485
+ return (
486
+ <Card>
487
+ <VStack gap={4}>
488
+ <VStack gap={0}>
489
+ <Heading level={3}>{name}</Heading>
490
+ <Text type="supporting" color="secondary">
491
+ {ticker}
492
+ </Text>
493
+ </VStack>
494
+ <Sparkline data={spark} positive={positive} />
495
+ <HStack gap={3} vAlign="center">
496
+ <Text type="display-3" weight="bold" hasTabularNumbers>
497
+ {price}
498
+ </Text>
499
+ <HStack gap={1} vAlign="center">
500
+ <Icon
501
+ icon={positive ? ArrowUp : ArrowDown}
502
+ size="xsm"
503
+ color={positive ? 'success' : 'error'}
504
+ />
505
+ <Text type="body" color="secondary" hasTabularNumbers>
506
+ {change}
507
+ </Text>
508
+ </HStack>
509
+ </HStack>
510
+ </VStack>
511
+ </Card>
512
+ );
513
+ }
514
+
515
+ function TrendSparkline({data, positive}: {data: number[]; positive: boolean}) {
516
+ const max = Math.max(...data);
517
+ const min = Math.min(...data);
518
+ const range = Math.max(1, max - min);
519
+ const step = 300 / data.length;
520
+ return (
521
+ <svg
522
+ viewBox="0 0 300 24"
523
+ width="100%"
524
+ height={24}
525
+ role="img"
526
+ aria-label="Daily trend">
527
+ {data.map((v, i) => {
528
+ const h = Math.max(2, ((v - min) / range) * 18);
529
+ return (
530
+ <rect
531
+ key={i}
532
+ x={i * step}
533
+ y={21 - h}
534
+ width={Math.max(1.5, step - 1)}
535
+ height={h}
536
+ rx={4}
537
+ fill={
538
+ positive ? 'var(--dracula-green)' : 'var(--dracula-red)'
539
+ }
540
+ />
541
+ );
542
+ })}
543
+ </svg>
544
+ );
545
+ }
546
+
547
+ function ColoredValue({
548
+ value,
549
+ isPositive,
550
+ }: {
551
+ value: string;
552
+ isPositive: boolean;
553
+ }) {
554
+ return <Badge label={value} variant={isPositive ? 'green' : 'red'} />;
555
+ }
556
+
557
+ const trendingColumns: TableColumn<StockRow>[] = [
558
+ {
559
+ key: 'ticker',
560
+ header: 'Ticker',
561
+ width: proportional(1),
562
+ renderCell: (row: StockRow) => (
563
+ <Text type="body" weight="bold">
564
+ {row.ticker}
565
+ </Text>
566
+ ),
567
+ },
568
+ {
569
+ key: 'price',
570
+ header: 'Price',
571
+ width: proportional(1),
572
+ renderCell: (row: StockRow) => (
573
+ <Text type="body" hasTabularNumbers>
574
+ {row.price}
575
+ </Text>
576
+ ),
577
+ },
578
+ {
579
+ key: 'dailyPts',
580
+ header: 'Daily Chg (pts)',
581
+ width: proportional(1),
582
+ renderCell: (row: StockRow) => {
583
+ const isPos = row.dailyPts >= 0;
584
+ const formatted = (isPos ? '+' : '') + row.dailyPts.toFixed(2);
585
+ return <ColoredValue value={formatted} isPositive={isPos} />;
586
+ },
587
+ },
588
+ {
589
+ key: 'dailyPct',
590
+ header: 'Daily Chg (%)',
591
+ width: proportional(1),
592
+ renderCell: (row: StockRow) => {
593
+ const isPos = row.dailyPct >= 0;
594
+ const formatted = (isPos ? '+' : '') + row.dailyPct.toFixed(2) + '%';
595
+ return <ColoredValue value={formatted} isPositive={isPos} />;
596
+ },
597
+ },
598
+ {
599
+ key: 'weekChg',
600
+ header: '52W Chg (%)',
601
+ width: proportional(1),
602
+ renderCell: (row: StockRow) => {
603
+ const isPos = row.weekChg >= 0;
604
+ const formatted = (isPos ? '+' : '') + row.weekChg.toFixed(1) + '%';
605
+ return <ColoredValue value={formatted} isPositive={isPos} />;
606
+ },
607
+ },
608
+ {
609
+ key: 'spark',
610
+ header: '24h Trend',
611
+ width: proportional(1),
612
+ renderCell: (row: StockRow) => (
613
+ <TrendSparkline data={row.spark} positive={row.dailyPct >= 0} />
614
+ ),
615
+ },
616
+ ];
617
+
618
+ function MetricCard({
619
+ value,
620
+ change,
621
+ label,
622
+ caption,
623
+ }: {
624
+ value: string;
625
+ change: string;
626
+ label: string;
627
+ caption: string;
628
+ }) {
629
+ const positive = !change.startsWith('-');
630
+ return (
631
+ <Card>
632
+ <VStack gap={2}>
633
+ <Heading level={4}>{label}</Heading>
634
+ <HStack gap={2} vAlign="center">
635
+ <Heading level={2}>{value}</Heading>
636
+ <HStack gap={1} vAlign="center">
637
+ <Icon
638
+ icon={positive ? ArrowUp : ArrowDown}
639
+ size="xsm"
640
+ color={positive ? 'success' : 'error'}
641
+ />
642
+ <Text type="body" color="secondary" hasTabularNumbers>
643
+ {change}
644
+ </Text>
645
+ </HStack>
646
+ </HStack>
647
+ <Text type="supporting" color="secondary">
648
+ {caption}
649
+ </Text>
650
+ </VStack>
651
+ </Card>
652
+ );
653
+ }
654
+
655
+ function AssetRow({
656
+ ticker,
657
+ name,
658
+ value,
659
+ change,
660
+ }: {
661
+ ticker: string;
662
+ name: string;
663
+ value: string;
664
+ change: string;
665
+ }) {
666
+ return (
667
+ <ListItem
668
+ label={<Text weight="bold">{ticker}</Text>}
669
+ description={name}
670
+ href="#/templates/dashboard-portfolio"
671
+ startContent={<Avatar name={ticker} size="md" />}
672
+ endContent={
673
+ <VStack gap={0} hAlign="end">
674
+ <Text type="body" hasTabularNumbers>
675
+ {value}
676
+ </Text>
677
+ <Badge
678
+ label={change}
679
+ variant={change.startsWith('-') ? 'red' : 'green'}
680
+ />
681
+ </VStack>
682
+ }
683
+ />
684
+ );
685
+ }
686
+
687
+ // ============= SIDENAV =============
688
+
689
+ // ============= MAIN COMPONENT =============
690
+
691
+ export default function DashboardPortfolio() {
692
+ const [timeRange, setTimeRange] = useState('1 year');
693
+
694
+ return (
695
+ <Layout
696
+ height="fill"
697
+ content={
698
+ <LayoutContent padding={6}>
699
+ <VStack gap={6}>
700
+ {/* Page header */}
701
+ <HStack hAlign="between" vAlign="center">
702
+ <Heading level={1}>The night vault</Heading>
703
+ <DropdownMenu
704
+ button={{
705
+ label: timeRange,
706
+ variant: 'secondary',
707
+ size: 'lg',
708
+ }}
709
+ hasChevron
710
+ items={[
711
+ {label: '1 month', onClick: () => setTimeRange('1 month')},
712
+ {label: '3 months', onClick: () => setTimeRange('3 months')},
713
+ {label: '6 months', onClick: () => setTimeRange('6 months')},
714
+ {label: '1 year', onClick: () => setTimeRange('1 year')},
715
+ {label: '5 years', onClick: () => setTimeRange('5 years')},
716
+ {label: 'All time', onClick: () => setTimeRange('All time')},
717
+ ]}
718
+ />
719
+ </HStack>
720
+
721
+ {/* KPI metric cards */}
722
+ <Grid columns={{minWidth: 280, repeat: 'fit'}} gap={4}>
723
+ {Array.from({length: Math.ceil(metrics.length / 2)}, (_, i) => (
724
+ <Grid key={i} columns={{minWidth: 280, repeat: 'fit'}} gap={4}>
725
+ {metrics.slice(i * 2, i * 2 + 2).map(m => (
726
+ <MetricCard key={m.label} {...m} />
727
+ ))}
728
+ </Grid>
729
+ ))}
730
+ </Grid>
731
+
732
+ {/* Chart + Top assets */}
733
+ <Grid columns={{minWidth: 280, max: 4}} gap={4}>
734
+ <GridSpan columns={3}>
735
+ <Card>
736
+ <VStack gap={4}>
737
+ <HStack hAlign="between" vAlign="center">
738
+ <Heading level={3}>Vault value</Heading>
739
+ <Link href="#/templates/dashboard-portfolio">View details</Link>
740
+ </HStack>
741
+ <PortfolioChart />
742
+ </VStack>
743
+ </Card>
744
+ </GridSpan>
745
+ <GridSpan columns={1}>
746
+ <Card>
747
+ <VStack gap={4}>
748
+ <HStack hAlign="between" vAlign="center">
749
+ <Heading level={3}>Top holdings</Heading>
750
+ <Link href="#/templates/dashboard-portfolio">View all</Link>
751
+ </HStack>
752
+ <List density="spacious">
753
+ {topAssets.map(asset => (
754
+ <AssetRow key={asset.ticker} {...asset} />
755
+ ))}
756
+ </List>
757
+ </VStack>
758
+ </Card>
759
+ </GridSpan>
760
+ </Grid>
761
+
762
+ <Divider />
763
+
764
+ {/* Market section */}
765
+ <HStack hAlign="between" vAlign="start">
766
+ <VStack gap={1}>
767
+ <Heading level={2}>Market at midnight</Heading>
768
+ <Text type="body" color="secondary">
769
+ Past 24 hours under moonlight
770
+ </Text>
771
+ </VStack>
772
+ <Button label="View more" variant="secondary" size="lg" />
773
+ </HStack>
774
+
775
+ {/* Market index cards */}
776
+ <Grid columns={{minWidth: 320, repeat: 'fit'}} gap={4}>
777
+ {marketIndices.map(m => (
778
+ <MarketCard key={m.ticker} {...m} />
779
+ ))}
780
+ </Grid>
781
+
782
+ {/* Trending stocks table */}
783
+ <Card>
784
+ <VStack gap={4}>
785
+ <Heading level={3}>Trending Stocks</Heading>
786
+ <Table<StockRow>
787
+ data={trendingStocks}
788
+ columns={trendingColumns}
789
+ idKey="id"
790
+ hasHover
791
+ dividers="rows"
792
+ />
793
+ </VStack>
794
+ </Card>
795
+ </VStack>
796
+ </LayoutContent>
797
+ }
798
+ />
799
+ );
800
+ }