muzical-ui 0.1.1

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 (117) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +30 -0
  3. package/CLAUDE.md +1 -0
  4. package/LICENSE.md +21 -0
  5. package/README.md +36 -0
  6. package/app/favicon.ico +0 -0
  7. package/app/globals.css +67 -0
  8. package/app/layout.tsx +49 -0
  9. package/app/musicbrainz/page.tsx +6 -0
  10. package/app/page.tsx +12 -0
  11. package/app/settings/display/page.tsx +11 -0
  12. package/app/settings/layout.tsx +19 -0
  13. package/app/settings/library/page.tsx +11 -0
  14. package/app/settings/page.tsx +5 -0
  15. package/app/settings/playback/page.tsx +11 -0
  16. package/app/settings/youtube/page.tsx +11 -0
  17. package/bin/stt-ui.js +25 -0
  18. package/components/AlbumCoverThumb.tsx +82 -0
  19. package/components/BrowsePanel.tsx +64 -0
  20. package/components/DisplaySettingsPanel.tsx +30 -0
  21. package/components/FavoriteStarButton.tsx +41 -0
  22. package/components/LibraryBrowser.tsx +1180 -0
  23. package/components/LibraryProvider.tsx +1023 -0
  24. package/components/LibraryScanNotification.tsx +62 -0
  25. package/components/LibraryScanOptionsSection.tsx +123 -0
  26. package/components/LibrarySettingsPanel.tsx +116 -0
  27. package/components/LibraryStatistics.tsx +54 -0
  28. package/components/MusicBrainzBrowser.tsx +395 -0
  29. package/components/MusicBrainzTrackRow.tsx +52 -0
  30. package/components/MusicPlayer.tsx +1531 -0
  31. package/components/PanelResizeHandle.tsx +65 -0
  32. package/components/PlaybackSettingsPanel.tsx +32 -0
  33. package/components/QueueLoadingSpinner.tsx +19 -0
  34. package/components/SettingsNav.tsx +37 -0
  35. package/components/SettingsOverview.tsx +34 -0
  36. package/components/SettingsShell.tsx +47 -0
  37. package/components/SettingsSwitchRow.tsx +38 -0
  38. package/components/ThemeProvider.tsx +75 -0
  39. package/components/ThemeToggle.tsx +38 -0
  40. package/components/YouTubeSettingsPanel.tsx +79 -0
  41. package/components/YouTubeStreamNotification.tsx +30 -0
  42. package/components/format-library-root-added.ts +13 -0
  43. package/components/settings-nav-items.ts +40 -0
  44. package/eslint.config.mjs +18 -0
  45. package/lib/format-duration.ts +9 -0
  46. package/lib/format-total-library-duration.ts +14 -0
  47. package/lib/library/audio-filename.ts +31 -0
  48. package/lib/library/collect-tracks-for-meta.ts +91 -0
  49. package/lib/library/compute-library-stats.ts +37 -0
  50. package/lib/library/constants.ts +27 -0
  51. package/lib/library/cover-bytes-cache.ts +59 -0
  52. package/lib/library/default-library-scan-preferences.ts +13 -0
  53. package/lib/library/extract-cover-bytes-from-audio-file.ts +41 -0
  54. package/lib/library/extract-cover-object-url-from-audio-file.ts +31 -0
  55. package/lib/library/favorite-keys.ts +14 -0
  56. package/lib/library/format-fs-access-error.ts +29 -0
  57. package/lib/library/idb.ts +270 -0
  58. package/lib/library/read-audio-metadata.ts +34 -0
  59. package/lib/library/read-stored-library-scan-preferences.ts +43 -0
  60. package/lib/library/resolve-track-file.ts +26 -0
  61. package/lib/library/scan-preferences-to-tree-options.ts +15 -0
  62. package/lib/library/scan-progress-label.ts +18 -0
  63. package/lib/library/scan-progress-percent.ts +19 -0
  64. package/lib/library/scan-progress-tick.ts +9 -0
  65. package/lib/library/scan-tree.ts +191 -0
  66. package/lib/library/write-stored-library-scan-preferences.ts +19 -0
  67. package/lib/mock-playlist.ts +47 -0
  68. package/lib/musicbrainz/build-musicbrainz-lucene-queries.ts +46 -0
  69. package/lib/musicbrainz/escape-lucene-term.ts +6 -0
  70. package/lib/musicbrainz/fetch-musicbrainz-json.ts +55 -0
  71. package/lib/musicbrainz/fetch-release-tracks.ts +53 -0
  72. package/lib/musicbrainz/group-tracks-by-album.ts +26 -0
  73. package/lib/musicbrainz/group-tracks-by-artist.ts +23 -0
  74. package/lib/musicbrainz/merge-tracks-by-id.ts +16 -0
  75. package/lib/musicbrainz/musicbrainz-recording-to-track.ts +42 -0
  76. package/lib/musicbrainz/pick-preferred-release.ts +32 -0
  77. package/lib/musicbrainz/pick-release-group-release-id.ts +12 -0
  78. package/lib/musicbrainz/release-group-artist-name.ts +13 -0
  79. package/lib/musicbrainz/search-musicbrainz-recordings.ts +33 -0
  80. package/lib/musicbrainz/search-musicbrainz-release-groups.ts +24 -0
  81. package/lib/musicbrainz/search-musicbrainz.ts +65 -0
  82. package/lib/musicbrainz/types.ts +43 -0
  83. package/lib/musicbrainz.ts +3 -0
  84. package/lib/playback/build-queue-from-snapshot.ts +49 -0
  85. package/lib/playback/parse-persisted-track.ts +45 -0
  86. package/lib/playback/read-stored-playback-snapshot.ts +45 -0
  87. package/lib/playback/write-stored-playback-snapshot.ts +19 -0
  88. package/lib/theme-constants.ts +4 -0
  89. package/lib/theme-init-script.ts +9 -0
  90. package/lib/youtube/clear-youtube-data-api-blocked.ts +8 -0
  91. package/lib/youtube/collect-youtube-prefetch-targets.ts +20 -0
  92. package/lib/youtube/is-youtube-quota-error-message.ts +7 -0
  93. package/lib/youtube/mark-youtube-data-api-blocked.ts +8 -0
  94. package/lib/youtube/prefetch-youtube-video-ids.ts +55 -0
  95. package/lib/youtube/read-stored-youtube-api-key.ts +16 -0
  96. package/lib/youtube/read-youtube-data-api-blocked.ts +12 -0
  97. package/lib/youtube/search-youtube-video-id.ts +60 -0
  98. package/lib/youtube/should-use-youtube-search-playback.ts +19 -0
  99. package/lib/youtube/write-stored-youtube-api-key.ts +18 -0
  100. package/next.config.ts +7 -0
  101. package/package.json +94 -0
  102. package/pnpm-workspace.yaml +6 -0
  103. package/postcss.config.mjs +7 -0
  104. package/public/file.svg +1 -0
  105. package/public/globe.svg +1 -0
  106. package/public/next.svg +1 -0
  107. package/public/vercel.svg +1 -0
  108. package/public/window.svg +1 -0
  109. package/tsconfig.json +34 -0
  110. package/types/file-system-access.d.ts +22 -0
  111. package/types/library-root-meta.ts +5 -0
  112. package/types/library-scan-preferences.ts +9 -0
  113. package/types/library-scan-progress.ts +8 -0
  114. package/types/persisted-playback-snapshot.ts +11 -0
  115. package/types/queue.ts +7 -0
  116. package/types/scan-tree-options.ts +6 -0
  117. package/types/track.ts +29 -0
@@ -0,0 +1,62 @@
1
+ 'use client'
2
+
3
+ import type { LibraryScanProgress } from '@/types/library-scan-progress'
4
+
5
+ type LibraryScanNotificationProps = {
6
+ progress: LibraryScanProgress | null
7
+ onDismiss: () => void
8
+ }
9
+
10
+ /**
11
+ * Fixed toast with a determinate progress bar while the library is scanned.
12
+ */
13
+ export default function LibraryScanNotification(props: LibraryScanNotificationProps) {
14
+ const { progress, onDismiss } = props
15
+ if (!progress) return null
16
+
17
+ return (
18
+ <div
19
+ role="status"
20
+ aria-live="polite"
21
+ aria-busy={progress.percent < 100}
22
+ className="pointer-events-auto fixed bottom-4 right-4 z-50 w-[min(100vw-2rem,22rem)] rounded-xl border border-zinc-200/90 bg-white/95 p-4 pr-10 shadow-lg shadow-zinc-900/10 backdrop-blur-sm dark:border-zinc-700/80 dark:bg-zinc-900/95 dark:shadow-black/40"
23
+ >
24
+ <button
25
+ type="button"
26
+ onClick={onDismiss}
27
+ className="absolute right-2 top-2 flex h-7 w-7 cursor-pointer items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
28
+ aria-label="Dismiss notification"
29
+ >
30
+ <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
31
+ <path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
32
+ </svg>
33
+ </button>
34
+ <p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
35
+ {progress.percent >= 100 ? 'Library scan complete' : 'Scanning library'}
36
+ </p>
37
+ <p className="mt-1 truncate text-xs text-zinc-600 dark:text-zinc-400">{progress.label}</p>
38
+ <div
39
+ role="progressbar"
40
+ aria-valuemin={0}
41
+ aria-valuemax={100}
42
+ aria-valuenow={progress.percent}
43
+ aria-label="Library scan progress"
44
+ className="mt-3 h-1.5 overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-800"
45
+ >
46
+ <div
47
+ className="h-full rounded-full bg-amber-500 transition-[width] duration-200 ease-out dark:bg-amber-400"
48
+ style={{ width: `${progress.percent}%` }}
49
+ />
50
+ </div>
51
+ <p className="mt-2 text-right text-[11px] tabular-nums text-zinc-500 dark:text-zinc-500">
52
+ {progress.percent}%
53
+ {progress.filesTotal > 0 ? (
54
+ <span className="text-zinc-400">
55
+ {' '}
56
+ · {progress.filesDone}/{progress.filesTotal} files
57
+ </span>
58
+ ) : null}
59
+ </p>
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,123 @@
1
+ 'use client'
2
+
3
+ import { useLibrary } from '@/components/LibraryProvider'
4
+ import { LIBRARY_AUDIO_EXTENSIONS } from '@/lib/library/constants'
5
+ import defaultLibraryScanPreferences from '@/lib/library/default-library-scan-preferences'
6
+ import SettingsSwitchRow from '@/components/SettingsSwitchRow'
7
+ import type { LibraryScanPreferences } from '@/types/library-scan-preferences'
8
+
9
+ const DEPTH_OPTIONS: readonly { value: number; label: string }[] = [
10
+ { value: 0, label: 'Unlimited' },
11
+ { value: 1, label: '1 level' },
12
+ { value: 2, label: '2 levels' },
13
+ { value: 3, label: '3 levels' },
14
+ { value: 5, label: '5 levels' },
15
+ { value: 10, label: '10 levels' },
16
+ ]
17
+
18
+ /**
19
+ * Scan depth, symlink follow, and per-extension include toggles.
20
+ */
21
+ export default function LibraryScanOptionsSection() {
22
+ const { scanPreferences, setScanPreferences, isScanning } = useLibrary()
23
+
24
+ const patch = (partial: Partial<LibraryScanPreferences>): void => {
25
+ setScanPreferences({ ...scanPreferences, ...partial })
26
+ }
27
+
28
+ const toggleExtension = (ext: string): void => {
29
+ const enabled = new Set(scanPreferences.enabledExtensions)
30
+ if (enabled.has(ext)) {
31
+ if (enabled.size <= 1) return
32
+ enabled.delete(ext)
33
+ } else {
34
+ enabled.add(ext)
35
+ }
36
+ patch({ enabledExtensions: [...enabled].sort() })
37
+ }
38
+
39
+ const resetExtensions = (): void => {
40
+ patch({ enabledExtensions: defaultLibraryScanPreferences().enabledExtensions })
41
+ }
42
+
43
+ return (
44
+ <section className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/40">
45
+ <h3 className="text-xs font-medium uppercase tracking-wider text-zinc-500">Scan options</h3>
46
+ <p className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
47
+ Applied on the next rescan. Symlink handling depends on your browser; when enabled, Muzical skips
48
+ directory cycles it can detect.
49
+ </p>
50
+
51
+ <div className="mt-6 flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
52
+ <label className="block min-w-0 flex-1">
53
+ <span className="text-xs font-medium uppercase tracking-wider text-zinc-500">Scan depth</span>
54
+ <select
55
+ value={scanPreferences.maxScanDepth}
56
+ disabled={isScanning}
57
+ onChange={(e) => patch({ maxScanDepth: Number.parseInt(e.target.value, 10) })}
58
+ className="mt-2 block w-full max-w-xs rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100"
59
+ aria-label="Maximum subdirectory scan depth"
60
+ >
61
+ {DEPTH_OPTIONS.map((opt) => (
62
+ <option key={opt.value} value={opt.value}>
63
+ {opt.label}
64
+ </option>
65
+ ))}
66
+ </select>
67
+ </label>
68
+ </div>
69
+
70
+ <div className="mt-6 border-t border-zinc-200 pt-6 dark:border-zinc-800">
71
+ <SettingsSwitchRow
72
+ title="Follow symlinks"
73
+ description="Follow directory links when the browser exposes them as folders. May increase scan time."
74
+ checked={scanPreferences.followSymlinks}
75
+ onChange={(followSymlinks) => patch({ followSymlinks })}
76
+ ariaLabel="Follow directory symlinks when scanning"
77
+ />
78
+ </div>
79
+
80
+ <div className="mt-6 border-t border-zinc-200 pt-6 dark:border-zinc-800">
81
+ <div className="flex flex-wrap items-baseline justify-between gap-3">
82
+ <div>
83
+ <h4 className="text-xs font-medium uppercase tracking-wider text-zinc-500">File types</h4>
84
+ <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
85
+ Only checked extensions are included in library scans.
86
+ </p>
87
+ </div>
88
+ <button
89
+ type="button"
90
+ disabled={isScanning}
91
+ onClick={resetExtensions}
92
+ className="shrink-0 text-xs font-medium text-amber-700 hover:text-amber-600 disabled:opacity-50 dark:text-amber-400 dark:hover:text-amber-300"
93
+ >
94
+ Enable all
95
+ </button>
96
+ </div>
97
+ <ul
98
+ className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4"
99
+ role="group"
100
+ aria-label="Audio file extensions to scan"
101
+ >
102
+ {LIBRARY_AUDIO_EXTENSIONS.map((ext) => {
103
+ const on = scanPreferences.enabledExtensions.includes(ext)
104
+ return (
105
+ <li key={ext}>
106
+ <label className="flex cursor-pointer items-center gap-2 rounded-lg border border-zinc-200 px-3 py-2 text-sm transition hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800/60">
107
+ <input
108
+ type="checkbox"
109
+ checked={on}
110
+ disabled={isScanning}
111
+ onChange={() => toggleExtension(ext)}
112
+ className="h-4 w-4 rounded border-zinc-300 text-amber-500 focus:ring-2 focus:ring-amber-500/20 dark:border-zinc-700 dark:bg-zinc-900"
113
+ />
114
+ <span className="font-mono text-xs text-zinc-800 dark:text-zinc-200">{ext}</span>
115
+ </label>
116
+ </li>
117
+ )
118
+ })}
119
+ </ul>
120
+ </div>
121
+ </section>
122
+ )
123
+ }
@@ -0,0 +1,116 @@
1
+ 'use client'
2
+
3
+ import { useLibrary } from '@/components/LibraryProvider'
4
+ import formatLibraryRootAdded from '@/components/format-library-root-added'
5
+ import LibraryScanOptionsSection from '@/components/LibraryScanOptionsSection'
6
+ import LibraryStatistics from '@/components/LibraryStatistics'
7
+ import SettingsSwitchRow from '@/components/SettingsSwitchRow'
8
+
9
+ /**
10
+ * Library settings: folders, rescan controls, and startup scan preference.
11
+ */
12
+ export default function LibrarySettingsPanel() {
13
+ const {
14
+ roots,
15
+ libraryTracks,
16
+ isScanning,
17
+ scanError,
18
+ hasDirectoryPicker,
19
+ addLibraryFolder,
20
+ removeLibraryFolder,
21
+ rescanAll,
22
+ autoRescanOnStartup,
23
+ setAutoRescanOnStartup,
24
+ } = useLibrary()
25
+
26
+ return (
27
+ <div className="flex flex-col gap-8">
28
+ <div>
29
+ <h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">Library</h2>
30
+ <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
31
+ Folders are read in the browser via the File System Access API.
32
+ </p>
33
+ </div>
34
+
35
+ <LibraryStatistics roots={roots} libraryTracks={libraryTracks} />
36
+
37
+ {scanError ? (
38
+ <p
39
+ className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900/50 dark:bg-red-950/40 dark:text-red-200"
40
+ role="alert"
41
+ >
42
+ {scanError}
43
+ </p>
44
+ ) : null}
45
+
46
+ <section className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/40">
47
+ <h3 className="text-xs font-medium uppercase tracking-wider text-zinc-500">Scan directories</h3>
48
+ <p className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
49
+ Pick one or more folders that contain your audio files. Subfolders are scanned. Handles are stored in
50
+ IndexedDB on this device so you do not have to pick them again on return visits.
51
+ </p>
52
+ {!hasDirectoryPicker ? (
53
+ <p className="mt-3 text-sm text-amber-800 dark:text-amber-300">
54
+ Folder selection is not available in this environment. Use Chrome or Edge on desktop.
55
+ </p>
56
+ ) : null}
57
+ <div className="mt-4 flex flex-wrap gap-2">
58
+ <button
59
+ type="button"
60
+ onClick={() => addLibraryFolder()}
61
+ disabled={!hasDirectoryPicker || isScanning}
62
+ className="rounded-full bg-amber-500 px-4 py-2 text-sm font-medium text-zinc-950 shadow-sm transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-50"
63
+ >
64
+ Add folder…
65
+ </button>
66
+ <button
67
+ type="button"
68
+ onClick={() => void rescanAll()}
69
+ disabled={roots.length === 0 || isScanning}
70
+ className="rounded-full border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 transition hover:bg-zinc-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
71
+ >
72
+ {isScanning ? 'Scanning…' : 'Rescan all'}
73
+ </button>
74
+ </div>
75
+ </section>
76
+
77
+ <LibraryScanOptionsSection />
78
+
79
+ <section className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/40">
80
+ <h3 className="text-xs font-medium uppercase tracking-wider text-zinc-500">Configured folders</h3>
81
+ {roots.length === 0 ? (
82
+ <p className="mt-4 text-sm text-zinc-600 dark:text-zinc-400">No folders yet. Add a library folder above.</p>
83
+ ) : (
84
+ <ul className="mt-4 divide-y divide-zinc-200 dark:divide-zinc-800" role="list">
85
+ {roots.map((r) => (
86
+ <li key={r.id} className="flex flex-wrap items-center justify-between gap-3 py-4 first:pt-0">
87
+ <div className="min-w-0">
88
+ <p className="truncate font-medium text-zinc-900 dark:text-zinc-100">{r.name}</p>
89
+ <p className="truncate text-xs text-zinc-500">Added {formatLibraryRootAdded(r.addedAt)}</p>
90
+ </div>
91
+ <button
92
+ type="button"
93
+ onClick={() => void removeLibraryFolder(r.id)}
94
+ disabled={isScanning}
95
+ className="shrink-0 rounded-full border border-zinc-300 px-3 py-1.5 text-xs font-medium text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
96
+ >
97
+ Remove
98
+ </button>
99
+ </li>
100
+ ))}
101
+ </ul>
102
+ )}
103
+ </section>
104
+
105
+ <section className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/40">
106
+ <SettingsSwitchRow
107
+ title="Rescan on startup"
108
+ description="When enabled, Muzical rescans all configured folders each time you open the app (if the browser already has folder access). When disabled, the last saved catalog loads immediately."
109
+ checked={autoRescanOnStartup}
110
+ onChange={setAutoRescanOnStartup}
111
+ ariaLabel="Rescan library on startup"
112
+ />
113
+ </section>
114
+ </div>
115
+ )
116
+ }
@@ -0,0 +1,54 @@
1
+ 'use client'
2
+
3
+ import { useMemo } from 'react'
4
+ import computeLibraryStats from '@/lib/library/compute-library-stats'
5
+ import formatTotalLibraryDuration from '@/lib/format-total-library-duration'
6
+ import type { LibraryRootMeta } from '@/types/library-root-meta'
7
+ import type { Track } from '@/types/track'
8
+
9
+ type LibraryStatisticsProps = {
10
+ roots: readonly LibraryRootMeta[]
11
+ libraryTracks: readonly Track[]
12
+ }
13
+
14
+ type StatCard = {
15
+ label: string
16
+ value: string
17
+ }
18
+
19
+ /**
20
+ * Summary counts for the library settings page header area.
21
+ */
22
+ export default function LibraryStatistics(props: LibraryStatisticsProps) {
23
+ const stats = useMemo(
24
+ () => computeLibraryStats(props.libraryTracks, props.roots.length),
25
+ [props.libraryTracks, props.roots.length],
26
+ )
27
+
28
+ const cards: StatCard[] = [
29
+ { label: 'Tracks', value: stats.trackCount.toLocaleString() },
30
+ { label: 'Folders', value: stats.folderCount.toLocaleString() },
31
+ { label: 'Artists', value: stats.artistCount.toLocaleString() },
32
+ { label: 'Albums', value: stats.albumCount.toLocaleString() },
33
+ { label: 'Playtime', value: formatTotalLibraryDuration(stats.totalDurationSec) },
34
+ ]
35
+
36
+ return (
37
+ <section
38
+ aria-label="Library statistics"
39
+ className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"
40
+ >
41
+ {cards.map((card) => (
42
+ <div
43
+ key={card.label}
44
+ className="rounded-2xl border border-zinc-200 bg-white px-4 py-3 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/40"
45
+ >
46
+ <p className="text-xs font-medium uppercase tracking-wider text-zinc-500">{card.label}</p>
47
+ <p className="mt-1 text-lg font-semibold tabular-nums tracking-tight text-zinc-900 dark:text-zinc-100">
48
+ {card.value}
49
+ </p>
50
+ </div>
51
+ ))}
52
+ </section>
53
+ )
54
+ }