astralyx-ui 0.1.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/LICENSE +21 -0
- package/README.md +134 -0
- package/bin/index.d.ts +2 -0
- package/bin/index.js +404 -0
- package/bin/registry.d.ts +37 -0
- package/bin/registry.js +69 -0
- package/package.json +89 -0
- package/registry/index.json +3914 -0
- package/registry/items/accordion.json +22 -0
- package/registry/items/address-input.json +21 -0
- package/registry/items/agent-steps.json +23 -0
- package/registry/items/alert-dialog.json +22 -0
- package/registry/items/alert-triage.json +25 -0
- package/registry/items/alert.json +21 -0
- package/registry/items/anomaly-chart.json +18 -0
- package/registry/items/api-keys.json +25 -0
- package/registry/items/aspect-ratio.json +18 -0
- package/registry/items/attachment-preview.json +23 -0
- package/registry/items/audit-log.json +26 -0
- package/registry/items/avatar.json +21 -0
- package/registry/items/badge.json +22 -0
- package/registry/items/banner.json +22 -0
- package/registry/items/benchmark-table.json +20 -0
- package/registry/items/bet-input.json +23 -0
- package/registry/items/blame-view.json +22 -0
- package/registry/items/branch-select.json +21 -0
- package/registry/items/breadcrumb.json +22 -0
- package/registry/items/bridge-status.json +24 -0
- package/registry/items/build-log.json +23 -0
- package/registry/items/bulk-action-bar.json +21 -0
- package/registry/items/bundle-treemap.json +20 -0
- package/registry/items/button-group.json +21 -0
- package/registry/items/button.json +22 -0
- package/registry/items/cache-stats.json +21 -0
- package/registry/items/calendar.json +25 -0
- package/registry/items/campaign-card.json +22 -0
- package/registry/items/candlestick-chart.json +18 -0
- package/registry/items/card-input.json +22 -0
- package/registry/items/card.json +21 -0
- package/registry/items/carousel.json +22 -0
- package/registry/items/cart.json +23 -0
- package/registry/items/chain-select.json +24 -0
- package/registry/items/changelog-entry.json +21 -0
- package/registry/items/chart.json +19 -0
- package/registry/items/checkbox.json +22 -0
- package/registry/items/checkout-summary.json +20 -0
- package/registry/items/citations.json +22 -0
- package/registry/items/code-block.json +23 -0
- package/registry/items/code-search.json +22 -0
- package/registry/items/cohort-table.json +19 -0
- package/registry/items/collapsible.json +23 -0
- package/registry/items/color-picker.json +24 -0
- package/registry/items/combobox.json +23 -0
- package/registry/items/command.json +23 -0
- package/registry/items/commit-graph.json +20 -0
- package/registry/items/commit-list.json +26 -0
- package/registry/items/composer.json +30 -0
- package/registry/items/confidence-meter.json +18 -0
- package/registry/items/connection-string.json +24 -0
- package/registry/items/container-list.json +24 -0
- package/registry/items/context-menu.json +20 -0
- package/registry/items/context-picker.json +25 -0
- package/registry/items/coordinate-input.json +21 -0
- package/registry/items/copy-button.json +22 -0
- package/registry/items/cost-breakdown.json +20 -0
- package/registry/items/coverage-report.json +22 -0
- package/registry/items/cron-schedule.json +24 -0
- package/registry/items/curl-command.json +18 -0
- package/registry/items/data-grid.json +24 -0
- package/registry/items/date-picker.json +24 -0
- package/registry/items/date-range-compare.json +24 -0
- package/registry/items/dependency-list.json +23 -0
- package/registry/items/deploy-list.json +27 -0
- package/registry/items/description-list.json +18 -0
- package/registry/items/device-list.json +24 -0
- package/registry/items/dialog.json +24 -0
- package/registry/items/diff-proposal.json +23 -0
- package/registry/items/diff-stat.json +18 -0
- package/registry/items/diff-view.json +23 -0
- package/registry/items/donut.json +19 -0
- package/registry/items/drawer.json +19 -0
- package/registry/items/dropdown-menu.json +24 -0
- package/registry/items/dropzone.json +22 -0
- package/registry/items/empty.json +19 -0
- package/registry/items/endpoint-list.json +24 -0
- package/registry/items/env-diff.json +23 -0
- package/registry/items/env-vars.json +24 -0
- package/registry/items/error-boundary.json +22 -0
- package/registry/items/eval-results.json +21 -0
- package/registry/items/feature-flag.json +23 -0
- package/registry/items/feedback.json +23 -0
- package/registry/items/field.json +19 -0
- package/registry/items/file-tree.json +21 -0
- package/registry/items/fingerprint-diff.json +23 -0
- package/registry/items/flame-graph.json +19 -0
- package/registry/items/fmt.json +16 -0
- package/registry/items/form.json +21 -0
- package/registry/items/fraud-verdict.json +24 -0
- package/registry/items/funnel.json +18 -0
- package/registry/items/gas-tracker.json +21 -0
- package/registry/items/gauge.json +18 -0
- package/registry/items/governance-proposal.json +24 -0
- package/registry/items/group.json +21 -0
- package/registry/items/health-checks.json +23 -0
- package/registry/items/heatmap-grid.json +19 -0
- package/registry/items/hover-card.json +20 -0
- package/registry/items/http-status.json +19 -0
- package/registry/items/incident-card.json +24 -0
- package/registry/items/input-file.json +22 -0
- package/registry/items/input-otp.json +19 -0
- package/registry/items/input.json +23 -0
- package/registry/items/invoice-list.json +24 -0
- package/registry/items/ip-cluster.json +22 -0
- package/registry/items/json-viewer.json +21 -0
- package/registry/items/jwt-inspector.json +25 -0
- package/registry/items/kanban.json +20 -0
- package/registry/items/kbd.json +18 -0
- package/registry/items/label-picker.json +24 -0
- package/registry/items/label.json +18 -0
- package/registry/items/leaderboard.json +23 -0
- package/registry/items/ledger-table.json +22 -0
- package/registry/items/lib-date.json +14 -0
- package/registry/items/lib-highlighter.json +16 -0
- package/registry/items/lib-styles.json +14 -0
- package/registry/items/lib-use-clipboard.json +14 -0
- package/registry/items/lib-utils.json +16 -0
- package/registry/items/lighthouse-score.json +19 -0
- package/registry/items/liquidity-position.json +23 -0
- package/registry/items/live-announcer.json +16 -0
- package/registry/items/location-picker.json +26 -0
- package/registry/items/log-viewer.json +24 -0
- package/registry/items/login-form.json +26 -0
- package/registry/items/map-embed.json +22 -0
- package/registry/items/market-table.json +23 -0
- package/registry/items/mask-input.json +19 -0
- package/registry/items/media-gallery.json +25 -0
- package/registry/items/mention-input.json +21 -0
- package/registry/items/menubar.json +21 -0
- package/registry/items/merge-conflict.json +20 -0
- package/registry/items/message.json +25 -0
- package/registry/items/migration-list.json +23 -0
- package/registry/items/mint-panel.json +23 -0
- package/registry/items/model-comparison.json +24 -0
- package/registry/items/model-select.json +20 -0
- package/registry/items/moderation-queue.json +26 -0
- package/registry/items/money-input.json +19 -0
- package/registry/items/multi-select.json +23 -0
- package/registry/items/multiplier-chart.json +18 -0
- package/registry/items/navigation-menu.json +23 -0
- package/registry/items/network-status.json +23 -0
- package/registry/items/nft-card.json +23 -0
- package/registry/items/notification-inbox.json +25 -0
- package/registry/items/number-input.json +21 -0
- package/registry/items/odds-display.json +21 -0
- package/registry/items/on-call-schedule.json +25 -0
- package/registry/items/order-book.json +19 -0
- package/registry/items/page-header.json +18 -0
- package/registry/items/pagination.json +21 -0
- package/registry/items/password-input.json +21 -0
- package/registry/items/payment-method.json +23 -0
- package/registry/items/payout-status.json +24 -0
- package/registry/items/permission-matrix.json +22 -0
- package/registry/items/pipeline.json +23 -0
- package/registry/items/popover.json +22 -0
- package/registry/items/port-table.json +22 -0
- package/registry/items/portfolio-balance.json +22 -0
- package/registry/items/postback-config.json +26 -0
- package/registry/items/presence.json +20 -0
- package/registry/items/price-ticker.json +21 -0
- package/registry/items/pricing-table.json +24 -0
- package/registry/items/primitive-collapsible.json +16 -0
- package/registry/items/primitive-dialog.json +16 -0
- package/registry/items/primitive-dismissable.json +14 -0
- package/registry/items/primitive-field.json +16 -0
- package/registry/items/primitive-focus-trap.json +14 -0
- package/registry/items/primitive-media-query.json +14 -0
- package/registry/items/primitive-popper.json +14 -0
- package/registry/items/primitive-portal.json +14 -0
- package/registry/items/primitive-radio-group.json +16 -0
- package/registry/items/primitive-router.json +14 -0
- package/registry/items/primitive-slot.json +16 -0
- package/registry/items/primitive-tabs.json +16 -0
- package/registry/items/progress.json +21 -0
- package/registry/items/prompt-input.json +25 -0
- package/registry/items/prompt-template.json +23 -0
- package/registry/items/pull-request-card.json +25 -0
- package/registry/items/query-editor.json +25 -0
- package/registry/items/query-plan.json +23 -0
- package/registry/items/queue-monitor.json +22 -0
- package/registry/items/radio-group.json +22 -0
- package/registry/items/range-slider.json +19 -0
- package/registry/items/rate-limit-meter.json +20 -0
- package/registry/items/rating.json +21 -0
- package/registry/items/reasoning-block.json +23 -0
- package/registry/items/regex-tester.json +25 -0
- package/registry/items/release-list.json +22 -0
- package/registry/items/request-builder.json +28 -0
- package/registry/items/resizable.json +22 -0
- package/registry/items/resource-meter.json +19 -0
- package/registry/items/response-viewer.json +26 -0
- package/registry/items/retrieval-results.json +23 -0
- package/registry/items/revenue-share-table.json +20 -0
- package/registry/items/review-thread.json +25 -0
- package/registry/items/risk-score.json +18 -0
- package/registry/items/root-cause-tree.json +20 -0
- package/registry/items/round-history.json +21 -0
- package/registry/items/runbook-steps.json +24 -0
- package/registry/items/schema-table.json +22 -0
- package/registry/items/schema-viewer.json +22 -0
- package/registry/items/scroll-area.json +18 -0
- package/registry/items/seed-phrase.json +22 -0
- package/registry/items/select.json +24 -0
- package/registry/items/separator.json +18 -0
- package/registry/items/service-status.json +21 -0
- package/registry/items/session-list.json +24 -0
- package/registry/items/sheet.json +24 -0
- package/registry/items/shortcut-sheet.json +19 -0
- package/registry/items/sidebar.json +24 -0
- package/registry/items/skeleton.json +21 -0
- package/registry/items/skip-link.json +19 -0
- package/registry/items/slider.json +21 -0
- package/registry/items/slo-budget.json +19 -0
- package/registry/items/sparkline.json +18 -0
- package/registry/items/spinner.json +20 -0
- package/registry/items/stack-trace.json +23 -0
- package/registry/items/staking-panel.json +24 -0
- package/registry/items/stat.json +21 -0
- package/registry/items/status-checks.json +23 -0
- package/registry/items/stepper.json +22 -0
- package/registry/items/streaming-text.json +19 -0
- package/registry/items/subscription-state.json +22 -0
- package/registry/items/suggestions.json +19 -0
- package/registry/items/swap-panel.json +22 -0
- package/registry/items/switch.json +21 -0
- package/registry/items/symbol-outline.json +22 -0
- package/registry/items/table-of-contents.json +19 -0
- package/registry/items/table.json +19 -0
- package/registry/items/tabs.json +22 -0
- package/registry/items/tag-input.json +21 -0
- package/registry/items/terminal.json +20 -0
- package/registry/items/test-results.json +24 -0
- package/registry/items/textarea.json +21 -0
- package/registry/items/theme-toggle.json +20 -0
- package/registry/items/theme.json +18 -0
- package/registry/items/thread-list.json +24 -0
- package/registry/items/ticket-card.json +24 -0
- package/registry/items/time-picker.json +23 -0
- package/registry/items/timeline.json +19 -0
- package/registry/items/timezone-select.json +18 -0
- package/registry/items/toast.json +22 -0
- package/registry/items/toggle.json +21 -0
- package/registry/items/token-amount.json +18 -0
- package/registry/items/token-approvals.json +25 -0
- package/registry/items/token-select.json +25 -0
- package/registry/items/token-usage.json +20 -0
- package/registry/items/tool-call.json +24 -0
- package/registry/items/toolbar.json +19 -0
- package/registry/items/tooltip.json +19 -0
- package/registry/items/topology-map.json +20 -0
- package/registry/items/tour.json +25 -0
- package/registry/items/traffic-split.json +20 -0
- package/registry/items/transaction-list.json +23 -0
- package/registry/items/transaction-status.json +25 -0
- package/registry/items/tree.json +21 -0
- package/registry/items/two-factor-setup.json +25 -0
- package/registry/items/typing-indicator.json +19 -0
- package/registry/items/typography.json +18 -0
- package/registry/items/upload-list.json +24 -0
- package/registry/items/uptime-strip.json +19 -0
- package/registry/items/user-menu.json +24 -0
- package/registry/items/utm-builder.json +24 -0
- package/registry/items/validator-list.json +23 -0
- package/registry/items/video-player.json +23 -0
- package/registry/items/virtual-list.json +18 -0
- package/registry/items/visually-hidden.json +19 -0
- package/registry/items/wallet-address.json +22 -0
- package/registry/items/wallet-connect.json +25 -0
- package/registry/items/webhook-inspector.json +26 -0
- package/registry/items/websocket-frames.json +24 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "message",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Message",
|
|
5
|
+
"description": "One turn in a conversation. The two roles are shaped differently on purpose — a user message is a bubble, an assistant message is full-width prose, because a long answer in a bubble is unreadable.",
|
|
6
|
+
"category": "AI",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"avatar",
|
|
12
|
+
"button",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-use-clipboard",
|
|
15
|
+
"lib-utils",
|
|
16
|
+
"tooltip"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"path": "components/ui/message.tsx",
|
|
21
|
+
"type": "registry:ui",
|
|
22
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Check, Copy, RefreshCw, ThumbsDown, ThumbsUp, User } from 'lucide-react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Button } from '@/components/ui/button'\nimport { Tooltip } from '@/components/ui/tooltip'\nimport { radius } from '@/lib/styles'\nimport { useClipboard } from '@/lib/use-clipboard'\nimport { cn } from '@/lib/utils'\n\n/**\n * One turn in a conversation.\n *\n * The two roles are shaped differently on purpose: a user message is a bubble\n * pinned to the trailing edge, an assistant message is full-width prose. Giving\n * both the same bubble makes a long answer unreadable, and makes it harder to\n * tell at a glance who said what.\n */\ntype MessageProps = ComponentProps<'div'> & {\n role: 'user' | 'assistant'\n /** Author name, used for the avatar's initials. */\n name?: string\n avatar?: ReactNode\n /** Action row under an assistant message. */\n actions?: boolean\n onCopy?: () => void\n onRetry?: () => void\n onVote?: (vote: 'up' | 'down') => void\n /** Raw text used by the copy button. Falls back to nothing. */\n copyText?: string\n copyLabel?: string\n /** Replaces `copyLabel` in the tooltip once copied. */\n copiedLabel?: string\n retryLabel?: string\n upvoteLabel?: string\n downvoteLabel?: string\n}\n\nfunction Message({\n className,\n role,\n name,\n avatar,\n actions = role === 'assistant',\n onCopy,\n onRetry,\n onVote,\n copyText,\n copyLabel = 'Copy',\n copiedLabel = 'Copied',\n retryLabel = 'Retry',\n upvoteLabel = 'Good response',\n downvoteLabel = 'Bad response',\n children,\n ...props\n}: MessageProps) {\n const { copy: writeClipboard, copied } = useClipboard()\n const user = role === 'user'\n\n function copy() {\n if (copyText) void writeClipboard(copyText)\n onCopy?.()\n }\n\n return (\n <div\n data-slot=\"message\"\n data-role={role}\n className={cn(\n 'flex w-full gap-3',\n user ? 'justify-end' : 'justify-start',\n className,\n )}\n {...props}\n >\n {!user && (avatar ?? <Avatar size=\"sm\" name={name ?? 'Assistant'} />)}\n\n <div className={cn('min-w-0 space-y-2', user ? 'max-w-[80%]' : 'flex-1')}>\n <div\n className={cn(\n 'text-sm leading-relaxed',\n user\n ? cn('bg-secondary text-secondary-foreground px-4 py-2.5', radius.panel)\n : 'text-foreground',\n )}\n >\n {children}\n </div>\n\n {actions && (\n <div className=\"flex items-center gap-0.5\">\n <Tooltip content={copied ? copiedLabel : copyLabel}>\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={copyLabel} onClick={copy}>\n {copied ? <Check /> : <Copy />}\n </Button>\n </Tooltip>\n {onRetry && (\n <Tooltip content=\"Try again\">\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={retryLabel} onClick={onRetry}>\n <RefreshCw />\n </Button>\n </Tooltip>\n )}\n {onVote && (\n <>\n <Tooltip content={upvoteLabel}>\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={upvoteLabel} onClick={() => onVote('up')}>\n <ThumbsUp />\n </Button>\n </Tooltip>\n <Tooltip content={downvoteLabel}>\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={downvoteLabel} onClick={() => onVote('down')}>\n <ThumbsDown />\n </Button>\n </Tooltip>\n </>\n )}\n </div>\n )}\n </div>\n\n {user && (avatar ?? <Avatar size=\"sm\" name={name ?? 'You'} fallback={<User className=\"size-3.5\" />} />)}\n </div>\n )\n}\n\n/**\n * The three-dot pulse shown while a reply is being generated.\n *\n * `role=\"status\"` with a screen-reader label, because a purely visual animation\n * tells a screen-reader user nothing about why the interface has gone quiet.\n */\nfunction MessagePending({\n className,\n label = 'Generating a response',\n ...props\n}: ComponentProps<'div'> & { label?: string }) {\n return (\n <div\n role=\"status\"\n data-slot=\"message-pending\"\n className={cn('flex items-center gap-3', className)}\n {...props}\n >\n <Avatar size=\"sm\" name=\"Assistant\" />\n <span className=\"flex gap-1\" aria-hidden=\"true\">\n {[0, 1, 2].map((index) => (\n <span\n key={index}\n className=\"bg-muted-foreground/60 size-1.5 rounded-full motion-safe:animate-[message-dot_1.2s_ease-in-out_infinite]\"\n style={{ animationDelay: `${index * 0.16}s` }}\n />\n ))}\n </span>\n <span className=\"sr-only\">{label}</span>\n </div>\n )\n}\n\nexport { Message, MessagePending }\nexport type { MessageProps }\n"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "migration-list",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Migration List",
|
|
5
|
+
"description": "Applied and pending migrations, sorted by version. A migration applied after a later version is flagged — that is the signature of two branches merged without a rebase, and it means this schema matches no single branch.",
|
|
6
|
+
"category": "Database",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"fmt",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/migration-list.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Check, Clock, TriangleAlert, X } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Database migrations, applied and pending.\n *\n * Order is the version string, never the array order. Migrations are named by\n * timestamp precisely so they sort deterministically, and a list that trusts\n * the order it was handed will show a rebase artefact as the real sequence.\n *\n * A migration applied *out of order* — one whose version predates an\n * already-applied migration — is flagged. That is the signature of two branches\n * merged without rebasing, and it means the schema on this database is not the\n * schema any single branch describes.\n *\n * Irreversible migrations are marked. Knowing there is no down-path before you\n * run something is the difference between a rollback and a restore from backup.\n */\nexport type MigrationState = 'applied' | 'pending' | 'failed' | 'rolled-back'\n\nexport type Migration = {\n /** Sortable identifier — usually a timestamp prefix. */\n version: string\n name: ReactNode\n state: MigrationState\n appliedAt?: Date\n /** Execution time in milliseconds. */\n duration?: number\n /** No down migration exists. */\n irreversible?: boolean\n error?: ReactNode\n}\n\nconst STATE = {\n applied: { label: 'Applied', color: 'green', Icon: Check },\n pending: { label: 'Pending', color: 'amber', Icon: Clock },\n failed: { label: 'Failed', color: 'destructive', Icon: X },\n 'rolled-back': { label: 'Rolled back', color: 'neutral', Icon: TriangleAlert },\n} as const\n\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_PENDING_SUMMARY: (count: number) => ReactNode = (count) => `${count} pending`\n\nfunction MigrationList({\n migrations,\n now,\n locale = 'en-GB',\n emptyLabel = 'No migrations',\n irreversibleLabel = 'irreversible',\n outOfOrderLabel = 'out of order',\n outOfOrderNote = 'Applied after a later version — the usual sign of two branches merged without a rebase.',\n pendingSummary = DEFAULT_PENDING_SUMMARY,\n className,\n ...props\n}: ComponentProps<'div'> & {\n migrations: Migration[]\n now?: Date\n locale?: string\n emptyLabel?: ReactNode\n irreversibleLabel?: ReactNode\n outOfOrderLabel?: ReactNode\n outOfOrderNote?: ReactNode\n pendingSummary?: (count: number) => ReactNode\n}) {\n // Sorted by version, never by the order the caller happened to supply.\n const rows = [...migrations].sort((a, b) => a.version.localeCompare(b.version))\n\n // Walk in the order they were actually applied: a migration whose version\n // precedes one already run is a merge artefact, not a sequence.\n const ordered = [...rows].sort((a, b) => {\n const at = a.appliedAt?.getTime() ?? Infinity\n const bt = b.appliedAt?.getTime() ?? Infinity\n return at - bt\n })\n const outOfOrder = new Set<string>()\n let seen = ''\n for (const row of ordered) {\n if (row.state !== 'applied') continue\n if (seen && row.version < seen) outOfOrder.add(row.version)\n else seen = row.version\n }\n\n const pending = rows.filter((row) => row.state === 'pending').length\n\n if (rows.length === 0) {\n return (\n <div className={cn(surface, radius.surface, className)} {...props}>\n <p className=\"text-muted-foreground p-8 text-center text-sm\">{emptyLabel}</p>\n </div>\n )\n }\n\n return (\n <div\n data-slot=\"migration-list\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n {pending > 0 && (\n <div className=\"border-border flex items-center gap-2 border-b p-3\">\n <Clock className=\"text-muted-foreground size-4 shrink-0\" aria-hidden=\"true\" />\n <span className=\"text-sm font-medium\">{pendingSummary(pending)}</span>\n </div>\n )}\n\n <ul className=\"divide-border/60 list-none divide-y\">\n {rows.map((migration) => {\n const meta = STATE[migration.state]\n const Icon = meta.Icon\n const misordered = outOfOrder.has(migration.version)\n\n return (\n <li key={migration.version} className=\"flex items-start gap-3 p-3\">\n <Icon\n className={cn(\n 'mt-0.5 size-4 shrink-0',\n migration.state === 'applied' && 'text-[var(--green-soft-foreground)]',\n migration.state === 'failed' && 'text-[var(--destructive-soft-foreground)]',\n migration.state === 'pending' && 'text-[var(--amber-soft-foreground)]',\n migration.state === 'rolled-back' && 'text-muted-foreground',\n )}\n aria-hidden=\"true\"\n />\n\n <div className=\"min-w-0 flex-1\">\n <p className=\"flex flex-wrap items-center gap-2\">\n <code className=\"text-muted-foreground shrink-0 font-mono text-xs\">\n {migration.version}\n </code>\n <span className=\"min-w-0 flex-1 truncate text-sm\">{migration.name}</span>\n {migration.irreversible && (\n <Badge size=\"sm\" color=\"amber\">\n {irreversibleLabel}\n </Badge>\n )}\n {misordered && (\n <Badge size=\"sm\" color=\"destructive\">\n {outOfOrderLabel}\n </Badge>\n )}\n </p>\n\n <p className=\"text-muted-foreground mt-0.5 flex flex-wrap gap-x-3 text-xs\">\n <span>{meta.label}</span>\n {migration.appliedAt && (\n <Fmt type=\"relative\" value={migration.appliedAt} now={now} locale={locale} />\n )}\n {migration.duration !== undefined && (\n <span className=\"tabular-nums\">{migration.duration}ms</span>\n )}\n </p>\n\n {misordered && (\n <p className=\"text-[var(--destructive-soft-foreground)] mt-1 text-xs\">\n {outOfOrderNote}\n </p>\n )}\n {migration.error && (\n <p className=\"text-[var(--destructive-soft-foreground)] mt-1 font-mono text-xs\">\n {migration.error}\n </p>\n )}\n </div>\n </li>\n )\n })}\n </ul>\n </div>\n )\n}\n\nexport { MigrationList }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mint-panel",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Mint Panel",
|
|
5
|
+
"description": "Supply, price and quantity, with the total shown before the button. A price of \"0.08 ETH\" beside a quantity of 5 is not a number anyone should be multiplying in their head at a mint.",
|
|
6
|
+
"category": "Crypto",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"badge",
|
|
10
|
+
"button",
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils",
|
|
13
|
+
"number-input",
|
|
14
|
+
"progress"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/mint-panel.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { Button } from '@/components/ui/button'\nimport { NumberInput } from '@/components/ui/number-input'\nimport { Progress } from '@/components/ui/progress'\nimport { Badge } from '@/components/ui/badge'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An NFT mint: supply, price, quantity.\n *\n * The per-wallet cap is enforced against what the wallet has *already* minted,\n * not just the quantity in the box. That is the check people forget, and the\n * failure mode is a transaction that reverts after the user has paid gas — the\n * worst possible way to learn about a limit.\n *\n * Total cost is shown before the button, not after. A price of \"0.08 ETH\" with\n * a quantity of five is a number the user should not have to compute while\n * deciding.\n */\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_MINT_LABEL: (quantity: number) => ReactNode = (quantity) => `Mint ${quantity}`\n\nfunction MintPanel({\n title,\n minted,\n supply,\n price,\n priceLabel,\n maxPerWallet,\n alreadyMinted = 0,\n quantity: quantityProp,\n onQuantityChange,\n onMint,\n disabled = false,\n status,\n soldOutLabel = 'Sold out',\n mintedLabel = 'Minted',\n priceRowLabel = 'Price',\n totalLabel = 'Total',\n quantityLabel = 'Quantity',\n limitReachedLabel = 'Limit reached',\n mintLabel = DEFAULT_MINT_LABEL,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'title'> & {\n title?: ReactNode\n minted: number\n supply: number\n /** Price per item, as a number so the total can be computed. */\n price?: number\n /** Unit shown after the price, e.g. \"ETH\". */\n priceLabel?: string\n maxPerWallet?: number\n /** What this wallet has already minted, counted against the cap. */\n alreadyMinted?: number\n quantity?: number\n onQuantityChange?: (quantity: number) => void\n onMint?: () => void\n disabled?: boolean\n status?: ReactNode\n soldOutLabel?: ReactNode\n /** Precedes the minted-of-supply count. */\n mintedLabel?: ReactNode\n priceRowLabel?: ReactNode\n totalLabel?: ReactNode\n /** Accessible name for the quantity field. */\n quantityLabel?: string\n /** Action label once the per-wallet cap is hit. */\n limitReachedLabel?: ReactNode\n /** Action label, given the chosen quantity. */\n mintLabel?: (quantity: number) => ReactNode\n}) {\n const controlled = quantityProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(1)\n const quantity = controlled ? quantityProp : uncontrolled\n\n const soldOut = minted >= supply\n // The cap counts what is already held, not just what is in the box.\n const remainingForWallet =\n maxPerWallet === undefined ? Infinity : Math.max(0, maxPerWallet - alreadyMinted)\n const capped = remainingForWallet <= 0\n const remainingSupply = Math.max(0, supply - minted)\n const maxQuantity = Math.max(1, Math.min(remainingForWallet, remainingSupply))\n\n function setQuantity(next: number) {\n const clamped = Math.max(1, Math.min(next, maxQuantity))\n if (!controlled) setUncontrolled(clamped)\n onQuantityChange?.(clamped)\n }\n\n return (\n <div\n data-slot=\"mint-panel\"\n className={cn(surface, radius.surface, 'flex flex-col gap-4 p-4', className)}\n {...props}\n >\n {title && (\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className=\"text-sm font-medium\">{title}</span>\n {soldOut && (\n <Badge size=\"sm\" color=\"neutral\">\n {soldOutLabel}\n </Badge>\n )}\n </div>\n )}\n\n <div className=\"flex flex-col gap-1.5\">\n <div className=\"flex items-baseline justify-between gap-2 text-xs\">\n <span className=\"text-muted-foreground\">{mintedLabel}</span>\n <span className=\"tabular-nums\">\n {minted.toLocaleString()} / {supply.toLocaleString()}\n </span>\n </div>\n <Progress value={(minted / Math.max(supply, 1)) * 100} className=\"h-2\" />\n </div>\n\n {price !== undefined && (\n <dl className=\"flex flex-col gap-1.5 text-sm\">\n <div className=\"flex justify-between gap-2\">\n <dt className=\"text-muted-foreground\">{priceRowLabel}</dt>\n <dd className=\"tabular-nums\">\n {price} {priceLabel}\n </dd>\n </div>\n {/* Computed for the user, before the button. */}\n <div className=\"flex justify-between gap-2\">\n <dt className=\"text-muted-foreground\">{totalLabel}</dt>\n <dd className=\"font-medium tabular-nums\">\n {Number((price * quantity).toFixed(6))} {priceLabel}\n </dd>\n </div>\n </dl>\n )}\n\n {!soldOut && !capped && (\n <div className=\"flex items-center gap-2\">\n <NumberInput\n size=\"sm\"\n value={quantity}\n min={1}\n max={maxQuantity}\n aria-label={quantityLabel}\n onValueChange={(next) => setQuantity(next ?? 1)}\n className=\"w-32\"\n />\n {maxPerWallet !== undefined && (\n <span className=\"text-muted-foreground text-xs\">\n {remainingForWallet} of {maxPerWallet} left for this wallet\n </span>\n )}\n </div>\n )}\n\n {capped && !soldOut && (\n <p className=\"text-[var(--amber-soft-foreground)] text-xs\">\n This wallet has minted its maximum of {maxPerWallet}.\n </p>\n )}\n\n <Button disabled={disabled || soldOut || capped} onClick={onMint}>\n {soldOut ? soldOutLabel : capped ? limitReachedLabel : mintLabel(quantity)}\n </Button>\n\n {status && <div className=\"text-muted-foreground text-xs\">{status}</div>}\n </div>\n )\n}\n\nexport { MintPanel }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "model-comparison",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Model Comparison",
|
|
5
|
+
"description": "Two or more outputs side by side with a pick. Columns are equal width and height regardless of content, because a taller box reads as the better answer before a word of it is read.",
|
|
6
|
+
"category": "AI",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"button",
|
|
13
|
+
"fmt",
|
|
14
|
+
"lib-styles",
|
|
15
|
+
"lib-utils"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/model-comparison.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { Check } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface, type Responsive } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Two or more model outputs side by side, with a pick.\n *\n * Columns are equal width and equal height regardless of content, so neither\n * answer is visually favoured — a taller box reads as the more substantial one\n * before a word of it is read, which is exactly the bias a comparison is\n * supposed to avoid. Metadata sits in a footer for the same reason: latency and\n * cost are compared after the answers, not before them.\n *\n * Stacks below the breakpoint, where two columns would be too narrow to read.\n */\nexport type ModelOutput = {\n id: string\n model: string\n output: ReactNode\n /** Seconds. */\n latency?: number\n tokens?: number\n cost?: number\n badge?: ReactNode\n}\n\nconst RESPONSIVE_ROW = {\n sm: 'grid-cols-1 sm:grid-cols-2',\n md: 'grid-cols-1 md:grid-cols-2',\n lg: 'grid-cols-1 lg:grid-cols-2',\n} as const\n\nfunction ModelComparison({\n outputs,\n selected: selectedProp,\n onSelect,\n responsive = 'md',\n pickedLabel = 'Picked',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n outputs: ModelOutput[]\n selected?: string\n onSelect?: (id: string) => void\n responsive?: Responsive\n /** Badge on the chosen model. */\n pickedLabel?: ReactNode\n}) {\n const controlled = selectedProp !== undefined\n const [uncontrolled, setUncontrolled] = useState<string | undefined>()\n const selected = controlled ? selectedProp : uncontrolled\n\n function pick(id: string) {\n if (!controlled) setUncontrolled(id)\n onSelect?.(id)\n }\n\n return (\n <div\n data-slot=\"model-comparison\"\n className={cn(\n 'grid gap-3',\n responsive === false ? 'grid-cols-2' : RESPONSIVE_ROW[responsive],\n className,\n )}\n {...props}\n >\n {outputs.map((entry) => {\n const isSelected = selected === entry.id\n\n return (\n <article\n key={entry.id}\n data-selected={isSelected || undefined}\n className={cn(\n surface,\n radius.surface,\n 'flex min-w-0 flex-col transition-colors duration-150 ease-out motion-reduce:transition-none',\n isSelected && 'border-primary',\n )}\n >\n <header className=\"border-border flex items-center gap-2 border-b p-3\">\n <span className=\"min-w-0 flex-1 truncate text-sm font-medium\">\n {entry.model}\n </span>\n {entry.badge}\n {isSelected && (\n <Badge size=\"sm\" color=\"green\">\n <Check />\n {pickedLabel}\n </Badge>\n )}\n </header>\n\n <div className=\"text-muted-foreground flex-1 p-3 text-sm\">\n {entry.output}\n </div>\n\n <footer className=\"border-border flex flex-wrap items-center gap-x-3 gap-y-1 border-t p-2\">\n <span className=\"text-muted-foreground flex flex-wrap gap-x-3 text-xs tabular-nums\">\n {entry.latency !== undefined && (\n <span>\n <Fmt type=\"duration\" value={entry.latency} />\n </span>\n )}\n {entry.tokens !== undefined && (\n <span>\n <Fmt type=\"number\" value={entry.tokens} /> tokens\n </span>\n )}\n {entry.cost !== undefined && (\n <span>\n <Fmt type=\"currency\" value={entry.cost} currency=\"USD\" decimals={4} />\n </span>\n )}\n </span>\n\n {onSelect !== undefined || !controlled ? (\n <Button\n size=\"xs\"\n variant={isSelected ? 'default' : 'secondary'}\n className=\"ms-auto\"\n onClick={() => pick(entry.id)}\n >\n {isSelected ? 'Picked' : 'Pick this'}\n </Button>\n ) : null}\n </footer>\n </article>\n )\n })}\n </div>\n )\n}\n\nexport { ModelComparison }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "model-select",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Model Select",
|
|
5
|
+
"description": "Picks which model answers. A Select with the naming convention folded in, so \"Opus · most capable\" is scannable where a bare model id is not.",
|
|
6
|
+
"category": "AI",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"select"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "components/ui/model-select.tsx",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"content": "import type { ComponentProps } from 'react'\nimport { Sparkles } from 'lucide-react'\nimport { Select } from '@/components/ui/select'\n\n/**\n * Picks which model answers.\n *\n * A Select with the naming convention folded in: the family and the tier read\n * as one line, so \"Opus · most capable\" is scannable where a bare model id is\n * not.\n */\nexport type Model = {\n id: string\n name: string\n /** A short capability or cost note shown beside the name. */\n note?: string\n disabled?: boolean\n}\n\nfunction ModelSelect({\n models,\n size = 'sm',\n placeholder = 'Model',\n ...props\n}: Omit<ComponentProps<typeof Select>, 'options'> & { models: Model[] }) {\n return (\n <Select\n size={size}\n icon={<Sparkles />}\n placeholder={placeholder}\n options={models.map((model) => ({\n value: model.id,\n label: model.note ? `${model.name} · ${model.note}` : model.name,\n disabled: model.disabled,\n }))}\n {...props}\n />\n )\n}\n\nexport { ModelSelect }\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "moderation-queue",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Moderation Queue",
|
|
5
|
+
"description": "Reported content with its report reasons and a decision. Sensitive items are blurred until revealed, because a moderator should choose when to look at the worst thing in the queue.",
|
|
6
|
+
"category": "Admin",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"avatar",
|
|
12
|
+
"badge",
|
|
13
|
+
"button",
|
|
14
|
+
"checkbox",
|
|
15
|
+
"fmt",
|
|
16
|
+
"lib-styles",
|
|
17
|
+
"lib-utils"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
{
|
|
21
|
+
"path": "components/ui/moderation-queue.tsx",
|
|
22
|
+
"type": "registry:ui",
|
|
23
|
+
"content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { Check, Flag, TriangleAlert, X } from 'lucide-react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A review queue for reported content.\n *\n * Reported content is blurred until revealed. Moderators do not consent to\n * seeing every image that gets flagged, and a queue that renders graphic\n * material on load is a duty-of-care failure — the reveal is per item and\n * deliberate.\n *\n * Decisions are reported, never applied locally. Two moderators working the\n * same queue must not both act on one item because each interface hid it\n * optimistically; the server decides who won.\n *\n * The report count and reason distribution are shown together — ten reports for\n * spam is a different problem from ten reports across five unrelated reasons,\n * which usually means brigading.\n */\nexport type ModerationItem = {\n id: string\n author: ReactNode\n authorAvatar?: ReactNode\n content: ReactNode\n /** Blur until revealed. Default for anything reported as graphic. */\n sensitive?: boolean\n reportedAt: Date\n reportCount?: number\n /** Reason to count. */\n reasons?: Record<string, number>\n status?: 'pending' | 'approved' | 'removed'\n}\n\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_SELECT_ITEM_LABEL: (id: string) => string = (id) => `Select item ${id}`\nconst DEFAULT_SELECTED_LABEL: (count: number) => ReactNode = (count) => `${count} selected`\nconst DEFAULT_PENDING_LABEL: (count: number) => ReactNode = (count) => `${count} pending`\n\nfunction ModerationQueue({\n items,\n onDecide,\n onBulkDecide,\n now,\n locale = 'en-GB',\n emptyLabel = 'Queue is clear',\n selectAllLabel = 'Select all pending',\n clearSelectionLabel = 'Clear selection',\n selectItemLabel = DEFAULT_SELECT_ITEM_LABEL,\n selectedLabel = DEFAULT_SELECTED_LABEL,\n pendingLabel = DEFAULT_PENDING_LABEL,\n approveLabel = 'Approve',\n removeLabel = 'Remove',\n approvedLabel = 'Approved',\n removedLabel = 'Removed',\n reportedLabel = 'reported',\n revealLabel = 'Reveal reported content',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n items: ModerationItem[]\n onDecide?: (id: string, decision: 'approve' | 'remove') => void\n onBulkDecide?: (ids: string[], decision: 'approve' | 'remove') => void\n now?: Date\n locale?: string\n emptyLabel?: ReactNode\n selectAllLabel?: string\n clearSelectionLabel?: string\n selectItemLabel?: (id: string) => string\n selectedLabel?: (count: number) => ReactNode\n pendingLabel?: (count: number) => ReactNode\n approveLabel?: ReactNode\n removeLabel?: ReactNode\n approvedLabel?: ReactNode\n removedLabel?: ReactNode\n /** Precedes the report time. */\n reportedLabel?: ReactNode\n /** Button that unblurs a sensitive item. */\n revealLabel?: ReactNode\n}) {\n const [selected, setSelected] = useState<string[]>([])\n const [revealed, setRevealed] = useState<string[]>([])\n\n const pending = items.filter((item) => (item.status ?? 'pending') === 'pending')\n const allSelected = pending.length > 0 && pending.every((i) => selected.includes(i.id))\n\n return (\n <div\n data-slot=\"moderation-queue\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border flex flex-wrap items-center gap-2 border-b p-2\">\n <Checkbox\n aria-label={allSelected ? clearSelectionLabel : selectAllLabel}\n checked={allSelected}\n indeterminate={selected.length > 0 && !allSelected}\n onChange={() => setSelected(allSelected ? [] : pending.map((i) => i.id))}\n />\n <span className=\"text-muted-foreground text-xs\">\n {selected.length > 0 ? selectedLabel(selected.length) : pendingLabel(pending.length)}\n </span>\n\n {selected.length > 0 && onBulkDecide && (\n <div className=\"ms-auto flex gap-1.5\">\n <Button\n size=\"xs\"\n variant=\"secondary\"\n onClick={() => {\n onBulkDecide(selected, 'approve')\n setSelected([])\n }}\n >\n <Check />\n {approveLabel}\n </Button>\n <Button\n size=\"xs\"\n color=\"destructive\"\n onClick={() => {\n onBulkDecide(selected, 'remove')\n setSelected([])\n }}\n >\n <X />\n {removeLabel}\n </Button>\n </div>\n )}\n </div>\n\n {items.length === 0 ? (\n <p className=\"text-muted-foreground p-8 text-center text-sm\">{emptyLabel}</p>\n ) : (\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {items.map((item) => {\n const status = item.status ?? 'pending'\n const shown = revealed.includes(item.id) || !item.sensitive\n const reasons = Object.entries(item.reasons ?? {})\n\n return (\n <li key={item.id} className={cn('flex flex-col gap-3 p-3', status !== 'pending' && 'opacity-60')}>\n <div className=\"flex items-start gap-2.5\">\n {status === 'pending' && (\n <Checkbox\n aria-label={selectItemLabel(item.id)}\n checked={selected.includes(item.id)}\n className=\"mt-0.5\"\n onChange={() =>\n setSelected((current) =>\n current.includes(item.id)\n ? current.filter((id) => id !== item.id)\n : [...current, item.id],\n )\n }\n />\n )}\n\n {item.authorAvatar ?? <Avatar size=\"xs\" name={String(item.author)} className=\"mt-0.5 shrink-0\" />}\n\n <div className=\"min-w-0 flex-1\">\n <p className=\"flex flex-wrap items-center gap-2 text-sm\">\n <span className=\"font-medium\">{item.author}</span>\n {item.reportCount !== undefined && (\n <Badge size=\"sm\" color={item.reportCount >= 5 ? 'destructive' : 'amber'}>\n <Flag />\n {item.reportCount}\n </Badge>\n )}\n {status === 'approved' && (\n <Badge size=\"sm\" color=\"green\">\n {approvedLabel}\n </Badge>\n )}\n {status === 'removed' && (\n <Badge size=\"sm\" color=\"destructive\">\n {removedLabel}\n </Badge>\n )}\n </p>\n <p className=\"text-muted-foreground/80 text-xs\">\n {reportedLabel} <Fmt type=\"relative\" value={item.reportedAt} now={now} locale={locale} />\n </p>\n </div>\n </div>\n\n {/* Blurred until deliberately revealed. */}\n <div className=\"relative\">\n <div\n className={cn(\n 'text-muted-foreground text-sm',\n !shown && 'pointer-events-none blur-md select-none',\n )}\n >\n {item.content}\n </div>\n {!shown && (\n <div className=\"absolute inset-0 flex items-center justify-center\">\n <Button\n size=\"xs\"\n variant=\"secondary\"\n onClick={() => setRevealed((current) => [...current, item.id])}\n >\n <TriangleAlert />\n {revealLabel}\n </Button>\n </div>\n )}\n </div>\n\n {reasons.length > 0 && (\n <div className=\"flex flex-wrap gap-1.5\">\n {reasons.map(([reason, count]) => (\n <Badge key={reason} size=\"sm\">\n {reason} · {count}\n </Badge>\n ))}\n </div>\n )}\n\n {status === 'pending' && onDecide && (\n <div className=\"flex flex-wrap gap-2\">\n <Button size=\"xs\" variant=\"secondary\" onClick={() => onDecide(item.id, 'approve')}>\n <Check />\n {approveLabel}\n </Button>\n <Button size=\"xs\" color=\"destructive\" onClick={() => onDecide(item.id, 'remove')}>\n <X />\n {removeLabel}\n </Button>\n </div>\n )}\n </li>\n )\n })}\n </ul>\n )}\n </div>\n )\n}\n\nexport { ModerationQueue }\n"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "money-input",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Money Input",
|
|
5
|
+
"description": "A currency field that reads and writes integer minor units. Money that has been through a float has already lost — the value that leaves this control is a whole number of cents.",
|
|
6
|
+
"category": "Finance",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/money-input.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import { useMemo, useState, type ComponentProps } from 'react'\nimport { fieldBase, fieldInput, fieldSize } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A fiat amount field that reports integer minor units.\n *\n * `onValueChange` hands back cents, never a float. Money in a JavaScript number\n * is wrong in ways that only show up on the invoice — `19.99 * 3` is\n * 59.97000000000001 — so the boundary between the field and the caller is the\n * one place to fix it.\n *\n * The currency symbol and its side come from `Intl`, not a prop. It is \"$12.00\"\n * and \"12,00 €\" depending on locale, and hard-coding a prefix produces\n * confidently wrong output for half the world.\n *\n * Formatting happens on blur, never mid-keystroke: reformatting while someone\n * types moves the caret and makes the field feel broken.\n */\n/** \"US dollars\" rather than \"USD\", falling back to the code where unsupported. */\nfunction currencyName(code: string) {\n try {\n return new Intl.DisplayNames(['en'], { type: 'currency' }).of(code) ?? code\n } catch {\n return code\n }\n}\n\nfunction MoneyInput({\n value: valueProp,\n defaultValue,\n onValueChange,\n currency = 'USD',\n locale = 'en-GB',\n size = 'md',\n variant = 'default',\n error = false,\n disabled = false,\n min,\n max,\n className,\n ...props\n}: Omit<ComponentProps<'input'>, 'value' | 'defaultValue' | 'onChange' | 'size' | 'min' | 'max'> & {\n /** Minor units — cents. */\n value?: number\n defaultValue?: number\n onValueChange?: (minorUnits: number | undefined) => void\n currency?: string\n locale?: string\n size?: keyof typeof fieldSize\n variant?: 'default' | 'secondary' | 'ghost'\n error?: boolean\n min?: number\n max?: number\n}) {\n const parts = useMemo(() => {\n const formatted = new Intl.NumberFormat(locale, {\n style: 'currency',\n currency,\n }).formatToParts(0)\n const symbol = formatted.find((p) => p.type === 'currency')?.value ?? ''\n // Which side does the symbol sit on in this locale?\n const prefix = formatted[0]?.type === 'currency'\n const decimals =\n formatted.find((p) => p.type === 'fraction')?.value.length ?? 2\n return { symbol, prefix, decimals }\n }, [locale, currency])\n\n const toText = (minor: number | undefined) =>\n minor === undefined ? '' : (minor / 10 ** parts.decimals).toFixed(parts.decimals)\n\n const controlled = valueProp !== undefined\n const [text, setText] = useState(() => toText(controlled ? valueProp : defaultValue))\n const [focused, setFocused] = useState(false)\n\n // While focused the local text wins, so an external re-render cannot rewrite\n // what someone is halfway through typing. On blur the two are reconciled.\n const display = controlled && !focused ? toText(valueProp) : text\n\n // The currency lives in a sibling span, so it is adjacent text rather than\n // part of the field's name — \"1250\" with no unit is what a screen reader\n // would otherwise announce. When the caller has named the field themselves\n // (aria-label, aria-labelledby, or an id a <Label> points at) theirs wins;\n // otherwise fall back to naming the currency, which is the one thing this\n // control knows and the visual design communicates only in colour and glyph.\n const named =\n props['aria-label'] !== undefined ||\n props['aria-labelledby'] !== undefined ||\n props.id !== undefined\n const fallbackLabel = named ? undefined : `Amount in ${currencyName(currency)}`\n\n const VARIANT = {\n default: 'border-border bg-background border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'border border-transparent bg-transparent',\n }[variant]\n\n return (\n <div\n data-slot=\"money-input\"\n className={cn(\n fieldBase,\n fieldSize[size],\n VARIANT,\n error && 'border-destructive',\n 'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',\n disabled && 'pointer-events-none opacity-50',\n className,\n )}\n >\n {parts.prefix && (\n <span aria-hidden=\"true\" className=\"text-muted-foreground shrink-0\">\n {parts.symbol}\n </span>\n )}\n\n <input\n type=\"text\"\n inputMode=\"decimal\"\n disabled={disabled}\n value={display}\n onChange={(event) => {\n const next = event.target.value.replace(/[^\\d.,-]/g, '')\n setText(next)\n const parsed = Number.parseFloat(next.replace(',', '.'))\n onValueChange?.(\n next === '' || Number.isNaN(parsed)\n ? undefined\n : Math.round(parsed * 10 ** parts.decimals),\n )\n }}\n onFocus={(event) => {\n setFocused(true)\n props.onFocus?.(event)\n }}\n onBlur={() => {\n setFocused(false)\n // Reformat here, not per keystroke — mid-type reformatting moves the\n // caret and makes the field feel broken.\n const parsed = Number.parseFloat(text.replace(',', '.'))\n if (Number.isNaN(parsed)) return setText('')\n let minor = Math.round(parsed * 10 ** parts.decimals)\n if (min !== undefined) minor = Math.max(min, minor)\n if (max !== undefined) minor = Math.min(max, minor)\n setText(toText(minor))\n onValueChange?.(minor)\n }}\n aria-label={fallbackLabel}\n className={cn(fieldInput, 'text-end tabular-nums')}\n {...props}\n />\n\n {!parts.prefix && (\n <span aria-hidden=\"true\" className=\"text-muted-foreground shrink-0\">\n {parts.symbol}\n </span>\n )}\n </div>\n )\n}\n\nexport { MoneyInput }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "multi-select",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Multi Select",
|
|
5
|
+
"description": "Pick several options. The gap Select cannot fill — it is a single-value combobox by construction, which is why the LogViewer level filter had to become a threshold instead of a set.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils",
|
|
13
|
+
"primitive-dismissable",
|
|
14
|
+
"primitive-popper"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/multi-select.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import {\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type KeyboardEvent,\n type ReactNode,\n} from 'react'\nimport { Check, ChevronDown, Search, X } from 'lucide-react'\nimport { useDismissable } from '@/components/primitives/dismissable'\nimport { usePopper } from '@/components/primitives/popper'\nimport {\n fieldBase,\n fieldSize,\n focusRing,\n menuItem,\n menuSurface,\n radius,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Select several options from a list.\n *\n * The gap `Select` cannot fill: it is a single-value combobox by construction,\n * which is why LogViewer's level filter had to become a threshold instead of a\n * set. This is the set version.\n *\n * The trigger shows chips up to `maxShown` and then a count. Rendering every\n * chip makes the control grow without limit and pushes the rest of the form\n * around as options are picked — the count keeps the height stable.\n *\n * Selecting does not close the panel. Multi-select exists because people pick\n * more than one thing, and closing after each is the single most common\n * complaint about these controls.\n */\nexport type MultiSelectOption = {\n value: string\n label: string\n description?: string\n disabled?: boolean\n}\n\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_REMOVE_LABEL: (option: string) => string = (option) => `Remove ${option}`\n\nfunction MultiSelect({\n options,\n value: valueProp,\n defaultValue = [],\n onValueChange,\n placeholder = 'Select…',\n searchable = true,\n maxShown = 2,\n size = 'md',\n variant = 'default',\n error = false,\n disabled = false,\n searchPlaceholder = 'Filter',\n searchLabel = 'Filter options',\n emptyMessage = 'No options match.',\n clearLabel = 'Clear',\n removeLabel = DEFAULT_REMOVE_LABEL,\n className,\n triggerClassName,\n ...props\n}: Omit<ComponentProps<'div'>, 'defaultValue' | 'onChange'> & {\n options: MultiSelectOption[]\n value?: string[]\n defaultValue?: string[]\n onValueChange?: (value: string[]) => void\n placeholder?: string\n searchable?: boolean\n /** Chips shown before collapsing to a count. */\n maxShown?: number\n size?: keyof typeof fieldSize\n variant?: 'default' | 'secondary' | 'ghost'\n error?: boolean\n disabled?: boolean\n searchPlaceholder?: string\n /** Accessible name for the filter field. */\n searchLabel?: string\n emptyMessage?: ReactNode\n clearLabel?: ReactNode\n /** Accessible name for a chip's remove button. */\n removeLabel?: (option: string) => string\n triggerClassName?: string\n}) {\n const controlled = valueProp !== undefined\n const [uncontrolled, setUncontrolled] = useState<string[]>(defaultValue)\n const selected = controlled ? valueProp : uncontrolled\n\n const [open, setOpen] = useState(false)\n const [query, setQuery] = useState('')\n const [highlighted, setHighlighted] = useState(0)\n\n const triggerRef = useRef<HTMLButtonElement>(null)\n const panelRef = useRef<HTMLDivElement>(null)\n const searchRef = useRef<HTMLInputElement>(null)\n\n const { style } = usePopper({\n open,\n anchorRef: triggerRef,\n floatingRef: panelRef,\n side: 'bottom',\n align: 'start',\n offset: 4,\n matchAnchorWidth: true,\n })\n\n useDismissable({\n open,\n onDismiss: () => {\n setOpen(false)\n setQuery('')\n triggerRef.current?.focus()\n },\n refs: [triggerRef, panelRef],\n })\n\n const visible = useMemo(\n () =>\n options.filter((option) =>\n option.label.toLowerCase().includes(query.toLowerCase()),\n ),\n [options, query],\n )\n\n function setSelected(next: string[]) {\n if (!controlled) setUncontrolled(next)\n onValueChange?.(next)\n }\n\n function toggle(option: MultiSelectOption) {\n if (option.disabled) return\n setSelected(\n selected.includes(option.value)\n ? selected.filter((v) => v !== option.value)\n : [...selected, option.value],\n )\n }\n\n function onPanelKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setHighlighted((index) => Math.min(index + 1, visible.length - 1))\n } else if (event.key === 'ArrowUp') {\n event.preventDefault()\n setHighlighted((index) => Math.max(index - 1, 0))\n } else if (event.key === 'Enter' || event.key === ' ') {\n const option = visible[highlighted]\n if (!option) return\n event.preventDefault()\n toggle(option)\n } else if (event.key === 'Escape') {\n event.preventDefault()\n setOpen(false)\n triggerRef.current?.focus()\n }\n }\n\n const chosen = options.filter((option) => selected.includes(option.value))\n const shown = chosen.slice(0, maxShown)\n const overflow = chosen.length - shown.length\n\n const VARIANT = {\n default: 'border-border bg-background border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'hover:bg-accent border border-transparent bg-transparent',\n }[variant]\n\n return (\n <div\n data-slot=\"multi-select\"\n className={cn('relative w-full', className)}\n {...props}\n >\n <button\n ref={triggerRef}\n type=\"button\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n disabled={disabled}\n onClick={() => {\n setOpen(!open)\n if (!open) requestAnimationFrame(() => searchRef.current?.focus())\n }}\n className={cn(\n fieldBase,\n fieldSize[size],\n VARIANT,\n error && 'border-destructive',\n 'justify-between gap-1.5 text-start',\n 'disabled:pointer-events-none disabled:opacity-50',\n triggerClassName,\n )}\n >\n <span className=\"flex min-w-0 flex-1 flex-wrap items-center gap-1\">\n {chosen.length === 0 && (\n <span className=\"text-muted-foreground/70 truncate\">{placeholder}</span>\n )}\n\n {shown.map((option) => (\n <span\n key={option.value}\n className={cn(\n 'bg-secondary text-secondary-foreground inline-flex h-5 shrink-0 items-center gap-1 ps-2 pe-1 text-xs',\n radius.xs,\n )}\n >\n {option.label}\n <span\n role=\"button\"\n tabIndex={-1}\n aria-label={removeLabel(option.label)}\n onClick={(event) => {\n event.stopPropagation()\n setSelected(selected.filter((v) => v !== option.value))\n }}\n className=\"text-muted-foreground hover:text-foreground flex size-3.5 items-center justify-center rounded-sm\"\n >\n <X className=\"size-2.5\" />\n </span>\n </span>\n ))}\n\n {overflow > 0 && (\n <span className=\"text-muted-foreground shrink-0 text-xs tabular-nums\">\n +{overflow} more\n </span>\n )}\n </span>\n\n <ChevronDown\n className={cn(\n 'text-muted-foreground shrink-0 transition-transform duration-150 ease-out motion-reduce:transition-none',\n open && 'rotate-180',\n )}\n />\n </button>\n\n {open && (\n <div\n ref={panelRef}\n style={style}\n onKeyDown={onPanelKeyDown}\n className={cn(menuSurface, radius.surface, 'max-h-72 min-w-44 p-0 outline-none')}\n >\n {searchable && (\n <div className=\"border-border flex items-center gap-2 border-b p-2.5\">\n <Search className=\"text-muted-foreground size-3.5 shrink-0\" />\n <input\n ref={searchRef}\n value={query}\n placeholder={searchPlaceholder}\n aria-label={searchLabel}\n onChange={(event) => {\n setQuery(event.target.value)\n setHighlighted(0)\n }}\n className=\"placeholder:text-muted-foreground/70 min-w-0 flex-1 bg-transparent text-sm outline-none\"\n />\n </div>\n )}\n\n <ul\n role=\"listbox\"\n aria-multiselectable=\"true\"\n className=\"max-h-56 list-none overflow-y-auto p-1\"\n >\n {visible.map((option, index) => {\n const isSelected = selected.includes(option.value)\n return (\n <li key={option.value}>\n <button\n type=\"button\"\n role=\"option\"\n aria-selected={isSelected}\n data-highlighted={index === highlighted || undefined}\n disabled={option.disabled}\n onPointerEnter={() => setHighlighted(index)}\n // Deliberately does not close: picking several things is\n // the entire point of the control.\n onClick={() => toggle(option)}\n className={cn(menuItem, radius.control, 'text-start')}\n >\n <span className=\"min-w-0 flex-1\">\n <span className=\"block truncate\">{option.label}</span>\n {option.description && (\n <span className=\"text-muted-foreground block truncate text-xs\">\n {option.description}\n </span>\n )}\n </span>\n {isSelected && <Check className=\"size-3.5 shrink-0\" />}\n </button>\n </li>\n )\n })}\n\n {visible.length === 0 && (\n <li className=\"text-muted-foreground px-2.5 py-3 text-center text-sm\">\n {emptyMessage}\n </li>\n )}\n </ul>\n\n {chosen.length > 0 && (\n <div className=\"border-border flex items-center justify-between gap-2 border-t p-2.5\">\n <span className=\"text-muted-foreground text-xs tabular-nums\">\n {chosen.length} selected\n </span>\n <button\n type=\"button\"\n onClick={() => setSelected([])}\n className={cn(\n 'text-muted-foreground hover:text-foreground text-xs',\n radius.xs,\n focusRing,\n )}\n >\n {clearLabel}\n </button>\n </div>\n )}\n </div>\n )}\n </div>\n )\n}\n\nexport { MultiSelect }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "multiplier-chart",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Multiplier Chart",
|
|
5
|
+
"description": "The rising curve of a crash-style round. The crash is a state, not an animation that ends — the frozen curve and the final figure stay on screen so a player who looked away still sees what happened.",
|
|
6
|
+
"category": "Gaming",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-utils"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "components/ui/multiplier-chart.tsx",
|
|
14
|
+
"type": "registry:ui",
|
|
15
|
+
"content": "import { useId, type ComponentProps, type ReactNode } from 'react'\nimport { cn } from '@/lib/utils'\n\n/**\n * A live multiplier curve — the crash-game shape.\n *\n * The y-axis is logarithmic. These curves are exponential by construction, and\n * a linear axis renders everything below 10× as a flat line against a spike,\n * which is exactly the region the player is watching.\n *\n * The cash-out marker is drawn on the curve rather than in a legend, because\n * the question is always \"where did I get out relative to where it stopped\".\n *\n * Colour carries the state and nothing moves: this component updates several\n * times a second, and any transform animation would fight the next frame.\n */\nfunction MultiplierChart({\n values,\n current,\n crashed = false,\n cashedOutAt,\n height = 160,\n max,\n label,\n crashedLabel = 'Crashed',\n runningLabel = 'Running',\n logScaleLabel = 'log scale',\n className,\n ...props\n}: Omit<ComponentProps<'figure'>, 'height'> & {\n /** Multiplier samples over time, starting at 1. */\n values: number[]\n current?: number\n crashed?: boolean\n /** Where the player cashed out, if they did. */\n cashedOutAt?: number\n height?: number\n max?: number\n label?: ReactNode\n crashedLabel?: ReactNode\n runningLabel?: ReactNode\n /** Note shown once the curve switches to a logarithmic axis. */\n logScaleLabel?: ReactNode\n}) {\n const titleId = useId()\n if (values.length === 0) return null\n\n const peak = max ?? Math.max(...values, current ?? 1, 2)\n // Log scale: a linear axis flattens everything under 10× into a straight line.\n const scale = (value: number) =>\n Math.log(Math.max(value, 1)) / Math.log(Math.max(peak, 1.0001))\n\n const points = values\n .map((value, index) => {\n const x = (index / Math.max(values.length - 1, 1)) * 100\n const y = 60 - scale(value) * 58\n return `${x},${y}`\n })\n .join(' ')\n\n const colour = crashed\n ? 'var(--destructive)'\n : cashedOutAt !== undefined\n ? 'var(--green)'\n : 'var(--blue)'\n\n const cashOutIndex =\n cashedOutAt === undefined\n ? -1\n : values.findIndex((value) => value >= cashedOutAt)\n\n return (\n <figure\n data-slot=\"multiplier-chart\"\n className={cn('flex min-w-0 flex-col gap-2', className)}\n {...props}\n >\n <div className=\"relative\" style={{ height }}>\n <svg\n viewBox=\"0 0 100 60\"\n preserveAspectRatio=\"none\"\n role=\"img\"\n aria-labelledby={titleId}\n className=\"size-full\"\n >\n <title id={titleId}>\n {`Multiplier curve, ${crashed ? 'crashed' : 'running'} at ${(current ?? values.at(-1) ?? 1).toFixed(2)}×`}\n </title>\n\n <polygon\n points={`0,60 ${points} 100,60`}\n fill={colour}\n opacity={0.12}\n />\n <polyline\n points={points}\n fill=\"none\"\n stroke={colour}\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n vectorEffect=\"non-scaling-stroke\"\n />\n </svg>\n\n {/* On the curve, not in a legend. */}\n {cashOutIndex >= 0 && (\n <span\n aria-hidden=\"true\"\n className=\"bg-[var(--green)] ring-background absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2 [corner-shape:round]\"\n style={{\n left: `${(cashOutIndex / Math.max(values.length - 1, 1)) * 100}%`,\n top: `${((60 - scale(values[cashOutIndex]) * 58) / 60) * 100}%`,\n }}\n />\n )}\n\n <span\n className={cn(\n 'absolute inset-0 flex items-center justify-center text-4xl font-semibold tabular-nums',\n crashed\n ? 'text-[var(--destructive-soft-foreground)]'\n : cashedOutAt !== undefined\n ? 'text-[var(--green-soft-foreground)]'\n : 'text-foreground',\n )}\n aria-live=\"off\"\n >\n {(current ?? values.at(-1) ?? 1).toFixed(2)}×\n </span>\n </div>\n\n <figcaption className=\"text-muted-foreground flex flex-wrap items-center gap-x-3 text-xs\">\n {crashed ? (\n <span className=\"text-[var(--destructive-soft-foreground)] font-medium\">{crashedLabel}</span>\n ) : (\n <span>{runningLabel}</span>\n )}\n {cashedOutAt !== undefined && (\n <span className=\"text-[var(--green-soft-foreground)]\">\n Cashed out at {cashedOutAt.toFixed(2)}×\n </span>\n )}\n <span className=\"text-muted-foreground/60\">{logScaleLabel}</span>\n {label}\n </figcaption>\n </figure>\n )\n}\n\nexport { MultiplierChart }\n"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "navigation-menu",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Navigation Menu",
|
|
5
|
+
"description": "A site nav bar with mega-menu panels and hover intent — a short open delay so crossing the bar does not flash panels, and a longer close delay so the panel is reachable.",
|
|
6
|
+
"category": "Navigation",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils",
|
|
13
|
+
"primitive-dismissable",
|
|
14
|
+
"primitive-popper"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/navigation-menu.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { ChevronDown } from 'lucide-react'\nimport { useDismissable } from '@/components/primitives/dismissable'\nimport { usePopper } from '@/components/primitives/popper'\nimport {\n focusRing,\n interactive,\n menuSurface,\n radius,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A site navigation bar with mega-menu panels.\n *\n * Hover intent, not raw hover. Opening the instant the pointer touches a\n * trigger makes a nav bar flash panels as you cross it on the way somewhere\n * else, and closing the instant it leaves makes the panel impossible to reach —\n * the pointer has to cross the gap between trigger and panel. A short open\n * delay and a longer close delay fix both.\n *\n * Once any panel is open, switching between triggers is immediate. The delay\n * exists to decide whether you meant to open the menu at all; once that is\n * settled, waiting again is just latency.\n *\n * Keyboard users get click-to-open, and the panel is reachable by Tab because\n * it follows its trigger in the DOM.\n */\nexport type NavigationMenuEntry = {\n id: string\n label: string\n href?: string\n /** Presence makes this a panel trigger rather than a link. */\n content?: ReactNode\n}\n\nfunction NavigationMenu({\n entries,\n openDelay = 150,\n closeDelay = 250,\n className,\n ...props\n}: ComponentProps<'nav'> & {\n entries: NavigationMenuEntry[]\n openDelay?: number\n closeDelay?: number\n}) {\n const [open, setOpen] = useState<string | null>(null)\n const timer = useRef(0)\n\n useEffect(() => () => clearTimeout(timer.current), [])\n\n function schedule(next: string | null, delay: number) {\n clearTimeout(timer.current)\n timer.current = window.setTimeout(() => setOpen(next), delay)\n }\n\n return (\n <nav\n data-slot=\"navigation-menu\"\n onPointerLeave={() => schedule(null, closeDelay)}\n className={cn('flex items-center gap-0.5', className)}\n {...props}\n >\n {entries.map((entry) =>\n entry.content ? (\n <NavigationMenuItem\n key={entry.id}\n entry={entry}\n open={open === entry.id}\n // Immediate once something is already open — the delay was only\n // ever about deciding whether to open at all.\n onHover={() => schedule(entry.id, open ? 0 : openDelay)}\n onToggle={() => {\n clearTimeout(timer.current)\n setOpen(open === entry.id ? null : entry.id)\n }}\n onClose={() => {\n clearTimeout(timer.current)\n setOpen(null)\n }}\n />\n ) : (\n <a\n key={entry.id}\n href={entry.href}\n onPointerEnter={() => schedule(null, closeDelay)}\n className={cn(\n 'text-muted-foreground hover:text-foreground hover:bg-accent flex h-8 items-center px-3 text-sm font-medium',\n radius.control,\n interactive,\n focusRing,\n )}\n >\n {entry.label}\n </a>\n ),\n )}\n </nav>\n )\n}\n\nfunction NavigationMenuItem({\n entry,\n open,\n onHover,\n onToggle,\n onClose,\n}: {\n entry: NavigationMenuEntry\n open: boolean\n onHover: () => void\n onToggle: () => void\n onClose: () => void\n}) {\n const triggerRef = useRef<HTMLButtonElement>(null)\n const panelRef = useRef<HTMLDivElement>(null)\n\n const { style } = usePopper({\n open,\n anchorRef: triggerRef,\n floatingRef: panelRef,\n side: 'bottom',\n align: 'start',\n offset: 8,\n })\n\n useDismissable({\n open,\n onDismiss: () => {\n onClose()\n triggerRef.current?.focus()\n },\n refs: [triggerRef, panelRef],\n })\n\n return (\n <>\n <button\n ref={triggerRef}\n type=\"button\"\n aria-expanded={open}\n aria-haspopup=\"true\"\n onClick={onToggle}\n onPointerEnter={onHover}\n className={cn(\n 'text-muted-foreground hover:text-foreground hover:bg-accent flex h-8 items-center gap-1 px-3 text-sm font-medium',\n radius.control,\n interactive,\n focusRing,\n open && 'bg-accent text-foreground',\n )}\n >\n {entry.label}\n <ChevronDown\n className={cn(\n 'size-3.5 transition-transform duration-150 ease-out motion-reduce:transition-none',\n open && 'rotate-180',\n )}\n aria-hidden=\"true\"\n />\n </button>\n\n {open && (\n <div\n ref={panelRef}\n style={style}\n onPointerEnter={onHover}\n className={cn(menuSurface, radius.panel, 'w-max max-w-[min(48rem,calc(100vw-2rem))] p-4')}\n >\n {entry.content}\n </div>\n )}\n </>\n )\n}\n\n/** A titled column inside a panel. */\nfunction NavigationMenuSection({\n title,\n className,\n ...props\n}: ComponentProps<'div'> & { title?: ReactNode }) {\n return (\n <div className={cn('flex min-w-48 flex-col gap-1', className)} {...props}>\n {title && (\n <h3 className=\"text-muted-foreground mb-1 text-xs font-medium tracking-wide uppercase\">\n {title}\n </h3>\n )}\n </div>\n )\n}\n\n/** A link inside a panel: title plus a line of description. */\nfunction NavigationMenuLink({\n title,\n description,\n className,\n ...props\n}: Omit<ComponentProps<'a'>, 'title'> & {\n title: ReactNode\n description?: ReactNode\n}) {\n return (\n <a\n className={cn(\n 'hover:bg-accent flex flex-col gap-0.5 p-2',\n radius.control,\n interactive,\n focusRing,\n className,\n )}\n {...props}\n >\n <span className=\"text-sm font-medium\">{title}</span>\n {description && (\n <span className=\"text-muted-foreground text-xs\">{description}</span>\n )}\n </a>\n )\n}\n\nexport {\n NavigationMenu,\n NavigationMenuLink,\n NavigationMenuSection,\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "network-status",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Network Status",
|
|
5
|
+
"description": "RPC health: block height, latency, sync state. Staleness is derived from the age of the last block — a stuck node answers cheerfully with an old height, so \"connected\" from the node proves nothing.",
|
|
6
|
+
"category": "Crypto",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"fmt",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/network-status.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Activity, TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * RPC endpoint health: block height, latency, sync state.\n *\n * Staleness is derived from the age of the last block, not reported by the\n * node. A stuck node answers every request cheerfully with an old height — it\n * does not know it is stuck — so \"connected\" from the node itself proves\n * nothing. Comparing the last block time against the chain's expected block\n * time is what actually catches it.\n */\nfunction NetworkStatus({\n chain,\n blockHeight,\n lastBlockAt,\n now,\n latency,\n /** Seconds between blocks on this chain. Drives the staleness check. */\n blockTime = 12,\n peers,\n locale = 'en-GB',\n blockLabel = 'Block',\n lastBlockLabel = 'Last',\n latencyLabel = 'Latency',\n peersLabel = 'Peers',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n chain: ReactNode\n blockHeight?: number\n lastBlockAt?: Date\n now?: Date\n /** Milliseconds. */\n latency?: number\n blockTime?: number\n peers?: number\n locale?: string\n blockLabel?: ReactNode\n /** Precedes the last-block time. */\n lastBlockLabel?: ReactNode\n latencyLabel?: ReactNode\n peersLabel?: ReactNode\n}) {\n const reference = now ?? new Date()\n const age = lastBlockAt ? (reference.getTime() - lastBlockAt.getTime()) / 1000 : undefined\n\n // Three missed blocks is stuck, not slow.\n const stale = age !== undefined && age > blockTime * 3\n const slow = latency !== undefined && latency > 1000\n\n const state = stale ? 'stale' : slow ? 'degraded' : 'healthy'\n const tone = { healthy: 'green', degraded: 'amber', stale: 'destructive' } as const\n const label = { healthy: 'Synced', degraded: 'Slow', stale: 'Stale' } as const\n\n return (\n <div\n data-slot=\"network-status\"\n data-state={state}\n className={cn(surface, radius.surface, 'flex flex-wrap items-center gap-x-4 gap-y-2 p-3', className)}\n {...props}\n >\n <span className=\"flex items-center gap-2\">\n {stale ? (\n <TriangleAlert className=\"size-4 shrink-0 text-[var(--destructive-soft-foreground)]\" aria-hidden=\"true\" />\n ) : (\n <Activity\n className={cn(\n 'size-4 shrink-0',\n slow ? 'text-[var(--amber-soft-foreground)]' : 'text-[var(--green-soft-foreground)]',\n )}\n aria-hidden=\"true\"\n />\n )}\n <span className=\"text-sm font-medium\">{chain}</span>\n <Badge size=\"sm\" color={tone[state]}>\n {label[state]}\n </Badge>\n </span>\n\n <dl className=\"text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1 text-xs tabular-nums\">\n {blockHeight !== undefined && (\n <div className=\"flex gap-1.5\">\n <dt>{blockLabel}</dt>\n <dd className=\"text-foreground font-medium\">\n <Fmt type=\"number\" value={blockHeight} locale={locale} />\n </dd>\n </div>\n )}\n {lastBlockAt && (\n <div className=\"flex gap-1.5\">\n <dt>{lastBlockLabel}</dt>\n <dd className={cn(stale && 'text-[var(--destructive-soft-foreground)] font-medium')}>\n <Fmt type=\"relative\" value={lastBlockAt} now={reference} locale={locale} />\n </dd>\n </div>\n )}\n {latency !== undefined && (\n <div className=\"flex gap-1.5\">\n <dt>{latencyLabel}</dt>\n <dd className={cn(slow && 'text-[var(--amber-soft-foreground)] font-medium')}>\n {latency} ms\n </dd>\n </div>\n )}\n {peers !== undefined && (\n <div className=\"flex gap-1.5\">\n <dt>{peersLabel}</dt>\n <dd>{peers}</dd>\n </div>\n )}\n </dl>\n </div>\n )\n}\n\nexport { NetworkStatus }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nft-card",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "NFT Card",
|
|
5
|
+
"description": "A token tile: media, collection, price. Broken media resolves to a placeholder rather than collapsing the tile — IPFS gateways fail constantly and a grid that reflows on every 404 is unusable.",
|
|
6
|
+
"category": "Crypto",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"aspect-ratio",
|
|
12
|
+
"badge",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/nft-card.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { BadgeCheck, ImageOff } from 'lucide-react'\nimport { AspectRatio } from '@/components/ui/aspect-ratio'\nimport { Badge } from '@/components/ui/badge'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An NFT tile: media, collection, price.\n *\n * The media sits in a fixed `AspectRatio` and a broken image resolves to a\n * placeholder rather than collapsing the tile. NFT media is loaded from IPFS\n * gateways that fail constantly, and a grid that reflows every time one 404s is\n * unusable.\n *\n * Collection verification is a badge on the *collection*, never on the item.\n * Copied collections are the standard scam here, and putting a tick beside an\n * item's name implies something nobody has checked.\n */\nexport type NftCardProps = Omit<ComponentProps<'article'>, 'title'> & {\n name: ReactNode\n collection?: ReactNode\n /** Verified applies to the collection, not the token. */\n verified?: boolean\n image?: string\n tokenId?: ReactNode\n price?: ReactNode\n lastSale?: ReactNode\n rarity?: ReactNode\n href?: string\n footer?: ReactNode\n /** Accessible name for the verification tick. */\n verifiedLabel?: string\n priceLabel?: ReactNode\n lastSaleLabel?: ReactNode\n}\n\nfunction NftCard({\n name,\n collection,\n verified = false,\n image,\n tokenId,\n price,\n lastSale,\n rarity,\n href,\n footer,\n verifiedLabel = 'Verified collection',\n priceLabel = 'Price',\n lastSaleLabel = 'Last sale',\n className,\n ...props\n}: NftCardProps) {\n const body = (\n <>\n <AspectRatio ratio={1} className={cn('bg-muted overflow-hidden', radius.control)}>\n {image ? (\n <img\n src={image}\n alt=\"\"\n loading=\"lazy\"\n className=\"size-full object-cover\"\n // A dead gateway must not collapse the tile.\n onError={(event) => {\n event.currentTarget.style.display = 'none'\n event.currentTarget.nextElementSibling?.removeAttribute('hidden')\n }}\n />\n ) : null}\n <div\n hidden={Boolean(image)}\n className=\"text-muted-foreground/40 flex size-full items-center justify-center\"\n >\n <ImageOff className=\"size-8\" aria-hidden=\"true\" />\n </div>\n </AspectRatio>\n\n <div className=\"flex flex-col gap-1 pt-3\">\n {collection && (\n <span className=\"text-muted-foreground flex items-center gap-1 text-xs\">\n <span className=\"truncate\">{collection}</span>\n {verified && (\n <BadgeCheck\n className=\"size-3.5 shrink-0 text-[var(--blue-soft-foreground)]\"\n aria-label={verifiedLabel}\n />\n )}\n </span>\n )}\n\n <span className=\"flex items-baseline gap-2\">\n <span className=\"min-w-0 flex-1 truncate text-sm font-medium\">{name}</span>\n {tokenId && (\n <span className=\"text-muted-foreground/70 shrink-0 font-mono text-xs\">\n #{tokenId}\n </span>\n )}\n </span>\n\n {rarity && (\n <span className=\"flex\">\n <Badge size=\"sm\">{rarity}</Badge>\n </span>\n )}\n\n {(price || lastSale) && (\n <dl className=\"mt-1 flex items-baseline justify-between gap-2 text-xs\">\n {price && (\n <div className=\"min-w-0\">\n <dt className=\"text-muted-foreground/70\">{priceLabel}</dt>\n <dd className=\"truncate font-medium tabular-nums\">{price}</dd>\n </div>\n )}\n {lastSale && (\n <div className=\"min-w-0 text-end\">\n <dt className=\"text-muted-foreground/70\">{lastSaleLabel}</dt>\n <dd className=\"text-muted-foreground truncate tabular-nums\">{lastSale}</dd>\n </div>\n )}\n </dl>\n )}\n\n {footer}\n </div>\n </>\n )\n\n return (\n <article\n data-slot=\"nft-card\"\n className={cn(surface, radius.surface, 'flex flex-col p-3', className)}\n {...props}\n >\n {href ? (\n <a href={href} className={cn('flex flex-col', radius.control, focusRing)}>\n {body}\n </a>\n ) : (\n body\n )}\n </article>\n )\n}\n\nexport { NftCard }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "notification-inbox",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Notification Inbox",
|
|
5
|
+
"description": "A notification list grouped by day, with unread state. Unread is reported, never assumed — a panel that marks everything read on render clears what the user never saw.",
|
|
6
|
+
"category": "Product",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"avatar",
|
|
12
|
+
"badge",
|
|
13
|
+
"button",
|
|
14
|
+
"fmt",
|
|
15
|
+
"lib-styles",
|
|
16
|
+
"lib-utils"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"path": "components/ui/notification-inbox.tsx",
|
|
21
|
+
"type": "registry:ui",
|
|
22
|
+
"content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Check, Inbox } from 'lucide-react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Fmt } from '@/components/ui/fmt'\nimport { focusRing, interactive, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A notification list, grouped by day, with unread state.\n *\n * Unread is reported, never assumed. Marking things read on render is the\n * classic mistake: a panel that opens behind a click clears everything the user\n * never actually saw. Reading happens when someone acts, which is the caller's\n * call to make.\n *\n * Grouping by day rather than showing a flat list with timestamps — \"when\"\n * is the axis people scan a notification list along, exactly as with a commit\n * history.\n */\nexport type Notification = {\n id: string\n title: ReactNode\n description?: ReactNode\n time: Date\n read?: boolean\n actor?: string\n icon?: ReactNode\n onSelect?: () => void\n}\n\nfunction NotificationInbox({\n notifications,\n onRead,\n onReadAll,\n locale = 'en-GB',\n now,\n emptyLabel = 'Nothing new',\n title = 'Notifications',\n markAllLabel = 'Mark all',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n notifications: Notification[]\n onRead?: (id: string) => void\n onReadAll?: () => void\n locale?: string\n now?: Date\n emptyLabel?: ReactNode\n title?: ReactNode\n markAllLabel?: ReactNode\n}) {\n const [filter, setFilter] = useState<'all' | 'unread'>('all')\n\n const unread = notifications.filter((entry) => !entry.read).length\n const visible = filter === 'unread' ? notifications.filter((n) => !n.read) : notifications\n\n const groups = useMemo(() => {\n const heading = new Intl.DateTimeFormat(locale, {\n weekday: 'long',\n day: 'numeric',\n month: 'long',\n })\n const out: [string, Notification[]][] = []\n for (const entry of visible) {\n const key = heading.format(entry.time)\n const last = out.at(-1)\n if (last?.[0] === key) last[1].push(entry)\n else out.push([key, [entry]])\n }\n return out\n }, [visible, locale])\n\n return (\n <div\n data-slot=\"notification-inbox\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border flex items-center gap-2 border-b p-2\">\n <Inbox className=\"text-muted-foreground ms-1 size-4 shrink-0\" aria-hidden=\"true\" />\n <span className=\"text-sm font-medium\">{title}</span>\n {unread > 0 && <Badge size=\"sm\">{unread}</Badge>}\n\n <div className=\"ms-auto flex items-center gap-1\">\n {(['all', 'unread'] as const).map((value) => (\n <button\n key={value}\n type=\"button\"\n aria-pressed={filter === value}\n onClick={() => setFilter(value)}\n className={cn(\n 'h-7 px-2.5 text-xs font-medium',\n radius.control,\n interactive,\n focusRing,\n filter === value\n ? 'bg-secondary text-foreground'\n : 'text-muted-foreground hover:text-foreground',\n )}\n >\n {value === 'all' ? 'All' : 'Unread'}\n </button>\n ))}\n\n {onReadAll && unread > 0 && (\n <Button variant=\"ghost\" size=\"xs\" onClick={onReadAll}>\n <Check />\n {markAllLabel}\n </Button>\n )}\n </div>\n </div>\n\n <div className=\"max-h-96 overflow-y-auto\">\n {groups.map(([day, entries]) => (\n <section key={day}>\n <h3 className=\"bg-muted/40 text-muted-foreground sticky top-0 p-3 text-xs font-medium\">\n {day}\n </h3>\n\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {entries.map((entry) => (\n <li key={entry.id}>\n <button\n type=\"button\"\n onClick={() => {\n entry.onSelect?.()\n if (!entry.read) onRead?.(entry.id)\n }}\n className={cn(\n 'hover:bg-accent/40 flex w-full items-start gap-2.5 p-3 text-start',\n interactive,\n focusRing,\n )}\n >\n <span className=\"relative mt-0.5 shrink-0\">\n {entry.icon ?? <Avatar size=\"xs\" name={entry.actor ?? '?'} />}\n {!entry.read && (\n <span\n aria-hidden=\"true\"\n className=\"bg-primary absolute -end-0.5 -top-0.5 size-2 rounded-full [corner-shape:round]\"\n />\n )}\n </span>\n\n <span className=\"min-w-0 flex-1\">\n <span\n className={cn(\n 'block text-sm',\n entry.read ? 'text-muted-foreground' : 'font-medium',\n )}\n >\n {entry.title}\n {!entry.read && <span className=\"sr-only\"> (unread)</span>}\n </span>\n {entry.description && (\n <span className=\"text-muted-foreground mt-0.5 block text-xs\">\n {entry.description}\n </span>\n )}\n <span className=\"text-muted-foreground/70 mt-1 block text-xs\">\n <Fmt type=\"relative\" value={entry.time} now={now} locale={locale} />\n </span>\n </span>\n </button>\n </li>\n ))}\n </ul>\n </section>\n ))}\n\n {visible.length === 0 && (\n <p className=\"text-muted-foreground p-8 text-center text-sm\">{emptyLabel}</p>\n )}\n </div>\n </div>\n )\n}\n\nexport { NotificationInbox }\n"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "number-input",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Number Input",
|
|
5
|
+
"description": "A number field with steppers. Uses type=\"text\" with inputMode=\"decimal\", because the native number input silently discards what it cannot parse — a half-typed \"1e\" or \"-\" vanishes as you type it.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/number-input.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import { useRef, useState, type ComponentProps, type KeyboardEvent } from 'react'\nimport { Minus, Plus } from 'lucide-react'\nimport {\n disabledState,\n fieldBase,\n fieldInput,\n fieldSize,\n focusRing,\n radius,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A number field with steppers.\n *\n * `inputMode=\"decimal\"` with `type=\"text\"`, not `type=\"number\"`. The native\n * number input silently discards what it cannot parse, so a half-typed \"1e\" or\n * \"-\" vanishes as you type it; it also exposes a spinner that cannot be styled\n * and scrolls the value on wheel over the field. Keeping the text input means\n * the caller sees every keystroke and the value is parsed on commit.\n *\n * Clamping happens on blur rather than on each keystroke, so typing \"5\" on the\n * way to \"50\" in a field with a minimum of 10 is not rewritten under the\n * cursor.\n */\nfunction NumberInput({\n value: valueProp,\n defaultValue,\n onValueChange,\n min,\n max,\n step = 1,\n precision,\n size = 'md',\n variant = 'default',\n error = false,\n disabled = false,\n prefix,\n suffix,\n className,\n ...props\n}: Omit<ComponentProps<'input'>, 'value' | 'defaultValue' | 'onChange' | 'size' | 'type' | 'prefix'> & {\n value?: number\n defaultValue?: number\n onValueChange?: (value: number | undefined) => void\n min?: number\n max?: number\n step?: number\n /** Decimal places kept when stepping. Inferred from `step` when omitted. */\n precision?: number\n size?: keyof typeof fieldSize\n variant?: 'default' | 'secondary' | 'ghost'\n error?: boolean\n prefix?: string\n suffix?: string\n}) {\n const controlled = valueProp !== undefined\n const [text, setText] = useState(\n () => (controlled ? valueProp : defaultValue)?.toString() ?? '',\n )\n const inputRef = useRef<HTMLInputElement>(null)\n\n const display = controlled ? (valueProp?.toString() ?? '') : text\n const current = Number.parseFloat(display)\n\n // A step of 0.1 implies one decimal place; anything else would accumulate\n // float noise as you click.\n const places =\n precision ?? (String(step).includes('.') ? String(step).split('.')[1].length : 0)\n\n const clamp = (raw: number) => {\n const bounded = Math.min(max ?? Infinity, Math.max(min ?? -Infinity, raw))\n return Number(bounded.toFixed(places))\n }\n\n function commit(next: number | undefined) {\n if (!controlled) setText(next?.toString() ?? '')\n onValueChange?.(next)\n }\n\n function nudge(direction: 1 | -1) {\n const base = Number.isNaN(current) ? (min ?? 0) : current\n commit(clamp(base + direction * step))\n inputRef.current?.focus()\n }\n\n function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n nudge(1)\n } else if (event.key === 'ArrowDown') {\n event.preventDefault()\n nudge(-1)\n }\n }\n\n const VARIANT = {\n default: 'border-border bg-background border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'border border-transparent bg-transparent',\n }[variant]\n\n const stepper = (direction: 1 | -1) => {\n const atLimit =\n direction === 1\n ? max !== undefined && current >= max\n : min !== undefined && current <= min\n\n return (\n <button\n type=\"button\"\n tabIndex={-1}\n disabled={disabled || atLimit}\n aria-label={direction === 1 ? 'Increase' : 'Decrease'}\n onClick={() => nudge(direction)}\n className={cn(\n 'text-muted-foreground hover:text-foreground hover:bg-accent flex size-6 shrink-0 items-center justify-center',\n radius.xs,\n 'transition-colors duration-150 ease-out motion-reduce:transition-none',\n disabledState,\n )}\n >\n {direction === 1 ? <Plus className=\"size-3.5\" /> : <Minus className=\"size-3.5\" />}\n </button>\n )\n }\n\n return (\n <div\n data-slot=\"number-input\"\n className={cn(\n fieldBase,\n fieldSize[size],\n VARIANT,\n error && 'border-destructive',\n 'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',\n disabled && 'pointer-events-none opacity-50',\n className,\n )}\n >\n {prefix && (\n <span className=\"text-muted-foreground shrink-0 text-sm\">{prefix}</span>\n )}\n\n <input\n ref={inputRef}\n type=\"text\"\n inputMode=\"decimal\"\n role=\"spinbutton\"\n aria-valuenow={Number.isNaN(current) ? undefined : current}\n aria-valuemin={min}\n aria-valuemax={max}\n disabled={disabled}\n value={display}\n onKeyDown={onKeyDown}\n onChange={(event) => {\n const next = event.target.value\n if (!controlled) setText(next)\n const parsed = Number.parseFloat(next)\n onValueChange?.(next === '' ? undefined : Number.isNaN(parsed) ? undefined : parsed)\n }}\n onBlur={() => {\n // Clamp on blur, not per keystroke: rewriting \"5\" to \"10\" mid-type\n // fights the person entering \"50\".\n if (display === '') return commit(undefined)\n if (Number.isNaN(current)) return commit(undefined)\n commit(clamp(current))\n }}\n className={cn(fieldInput, 'tabular-nums', focusRing, 'focus-visible:ring-0')}\n {...props}\n />\n\n {suffix && (\n <span className=\"text-muted-foreground shrink-0 text-sm\">{suffix}</span>\n )}\n\n <span className=\"flex shrink-0 items-center gap-0.5\">\n {stepper(-1)}\n {stepper(1)}\n </span>\n </div>\n )\n}\n\nexport { NumberInput }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "odds-display",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Odds Display",
|
|
5
|
+
"description": "A selectable price in decimal, fractional or American. Decimal is the source of truth and the others are derived, so a format switch can never change what the bet actually pays.",
|
|
6
|
+
"category": "Gaming",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/odds-display.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { ArrowDown, ArrowUp } from 'lucide-react'\nimport { focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A selectable odds button that shows which way the price moved.\n *\n * Three formats, converted here rather than by the caller. Decimal, fractional\n * and American describe the same price and every operator has to render all\n * three for different markets — doing the conversion in each call site is how\n * they end up disagreeing.\n *\n * A drift is flagged with an arrow and a colour, which is the one thing a\n * bettor is watching. It is deliberately not animated: odds boards that flash\n * are unusable when several markets move at once.\n */\nexport type OddsFormat = 'decimal' | 'fractional' | 'american'\n\n/** Decimal is the source of truth; the others are derived from it. */\nfunction formatOdds(decimal: number, format: OddsFormat) {\n if (format === 'decimal') return decimal.toFixed(2)\n\n if (format === 'american') {\n if (decimal >= 2) return `+${Math.round((decimal - 1) * 100)}`\n return `${Math.round(-100 / (decimal - 1))}`\n }\n\n // Fractional: reduce (decimal - 1) to a simple fraction.\n const profit = decimal - 1\n let bestNumerator = 1\n let bestDenominator = 1\n let bestError = Infinity\n for (let denominator = 1; denominator <= 20; denominator++) {\n const numerator = Math.round(profit * denominator)\n const error = Math.abs(profit - numerator / denominator)\n if (error < bestError) {\n bestError = error\n bestNumerator = numerator\n bestDenominator = denominator\n }\n }\n return `${bestNumerator}/${bestDenominator}`\n}\n\nfunction OddsDisplay({\n label,\n odds,\n previousOdds,\n format = 'decimal',\n selected = false,\n suspended = false,\n onSelect,\n driftedLabel = 'drifted out',\n shortenedLabel = 'shortened',\n className,\n ...props\n}: Omit<ComponentProps<'button'>, 'onSelect'> & {\n label?: ReactNode\n /** Decimal odds. */\n odds: number\n previousOdds?: number\n format?: OddsFormat\n selected?: boolean\n suspended?: boolean\n onSelect?: () => void\n /** Accessible name when the price lengthens. */\n driftedLabel?: string\n shortenedLabel?: string\n}) {\n const drifted = previousOdds !== undefined && previousOdds !== odds\n const up = drifted && odds > previousOdds!\n const down = drifted && odds < previousOdds!\n\n return (\n <button\n type=\"button\"\n data-slot=\"odds-display\"\n aria-pressed={selected}\n disabled={suspended}\n onClick={onSelect}\n className={cn(\n 'flex min-w-20 flex-col items-center gap-0.5 px-3 py-2',\n radius.control,\n interactive,\n focusRing,\n selected\n ? 'bg-primary text-primary-foreground'\n : 'bg-secondary hover:bg-accent text-foreground',\n suspended && 'cursor-not-allowed opacity-50',\n className,\n )}\n {...props}\n >\n {label && (\n <span className={cn('truncate text-xs', selected ? 'opacity-80' : 'text-muted-foreground')}>\n {label}\n </span>\n )}\n\n <span className=\"flex items-center gap-1 text-sm font-semibold tabular-nums\">\n {suspended ? '—' : formatOdds(odds, format)}\n {/* Flagged, not animated: a flashing board is unusable. */}\n {!suspended && up && (\n <ArrowUp className=\"size-3 text-[var(--green-soft-foreground)]\" aria-label={driftedLabel} />\n )}\n {!suspended && down && (\n <ArrowDown className=\"size-3 text-[var(--destructive-soft-foreground)]\" aria-label={shortenedLabel} />\n )}\n </span>\n </button>\n )\n}\n\nexport { OddsDisplay, formatOdds }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "on-call-schedule",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "On-call Schedule",
|
|
5
|
+
"description": "A rotation across a window of days. Shifts are positioned proportionally rather than snapped to a day grid — rotations hand over at 09:00, and rounding shows the wrong person on call for most of two days.",
|
|
6
|
+
"category": "AIOps",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"avatar",
|
|
12
|
+
"badge",
|
|
13
|
+
"fmt",
|
|
14
|
+
"lib-styles",
|
|
15
|
+
"lib-utils",
|
|
16
|
+
"tooltip"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"path": "components/ui/on-call-schedule.tsx",
|
|
21
|
+
"type": "registry:ui",
|
|
22
|
+
"content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { Phone } from 'lucide-react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Badge } from '@/components/ui/badge'\nimport { Fmt } from '@/components/ui/fmt'\nimport { Tooltip } from '@/components/ui/tooltip'\nimport { dataPalette, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An on-call rotation across a window of days.\n *\n * Shifts are positioned proportionally in the window rather than snapped to a\n * day grid. Rotations hand over at 09:00 on a Tuesday, not at midnight, and a\n * calendar that rounds to whole days shows the wrong person as on call for\n * most of two of them.\n *\n * Who is on call *now* is stated in words above the timeline. That is the only\n * question this component is ever opened to answer, and making someone read it\n * off a bar chart during an incident is a poor trade.\n */\nexport type Shift = {\n id: string\n person: string\n start: Date\n end: Date\n /** e.g. \"Primary\", \"Secondary\" */\n layer?: string\n color?: string\n}\n\nfunction OnCallSchedule({\n shifts,\n start,\n end,\n now,\n locale = 'en-GB',\n title = 'On call now',\n nobodyLabel = 'Nobody',\n untilLabel = 'until',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n shifts: Shift[]\n start: Date\n end: Date\n now?: Date\n locale?: string\n title?: ReactNode\n /** Shown when no shift covers the current moment. */\n nobodyLabel?: ReactNode\n /** Precedes the shift's end time. */\n untilLabel?: ReactNode\n}) {\n const reference = now ?? new Date()\n const windowStart = start.getTime()\n const windowSpan = end.getTime() - windowStart || 1\n\n const layers = useMemo(() => {\n const map = new Map<string, Shift[]>()\n for (const shift of shifts) {\n const key = shift.layer ?? 'Rotation'\n map.set(key, [...(map.get(key) ?? []), shift])\n }\n return [...map.entries()]\n }, [shifts])\n\n const people = useMemo(\n () => [...new Set(shifts.map((shift) => shift.person))],\n [shifts],\n )\n const paletteFor = (person: string) =>\n dataPalette[people.indexOf(person) % dataPalette.length]\n\n const onCallNow = shifts.filter(\n (shift) => shift.start <= reference && shift.end > reference,\n )\n\n const dayLabels = useMemo(() => {\n const format = new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric' })\n const out: { label: string; offset: number }[] = []\n const cursor = new Date(start)\n cursor.setHours(0, 0, 0, 0)\n\n while (cursor <= end) {\n if (cursor >= start) {\n out.push({\n label: format.format(cursor),\n offset: ((cursor.getTime() - windowStart) / windowSpan) * 100,\n })\n }\n cursor.setDate(cursor.getDate() + 1)\n }\n return out\n }, [start, end, locale, windowStart, windowSpan])\n\n return (\n <div\n data-slot=\"on-call-schedule\"\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex flex-wrap items-center gap-2\">\n <Phone className=\"text-muted-foreground size-4 shrink-0\" aria-hidden=\"true\" />\n <span className=\"text-sm font-medium\">{title}</span>\n {onCallNow.length === 0 ? (\n <Badge size=\"sm\" color=\"destructive\">\n {nobodyLabel}\n </Badge>\n ) : (\n onCallNow.map((shift) => (\n <span key={shift.id} className=\"flex items-center gap-1.5\">\n <Avatar size=\"xs\" name={shift.person} />\n <span className=\"text-sm\">{shift.person}</span>\n {shift.layer && <Badge size=\"sm\">{shift.layer}</Badge>}\n <span className=\"text-muted-foreground text-xs\">\n {untilLabel} <Fmt type=\"relative\" value={shift.end} now={reference} locale={locale} />\n </span>\n </span>\n ))\n )}\n </div>\n\n <div className=\"relative\">\n <div className=\"text-muted-foreground/70 relative mb-1 h-4 text-[10px]\">\n {dayLabels.map((day) => (\n <span\n key={day.label}\n className=\"absolute whitespace-nowrap\"\n style={{ left: `${day.offset}%` }}\n >\n {day.label}\n </span>\n ))}\n </div>\n\n <div className=\"flex flex-col gap-2\">\n {layers.map(([layer, entries]) => (\n <div key={layer} className=\"flex items-center gap-2\">\n <span className=\"text-muted-foreground w-20 shrink-0 truncate text-xs\">\n {layer}\n </span>\n\n <div className=\"bg-secondary relative h-7 min-w-0 flex-1 overflow-hidden rounded-md\">\n {entries.map((shift) => {\n // Proportional, not snapped: handovers are mid-day.\n const left =\n ((shift.start.getTime() - windowStart) / windowSpan) * 100\n const width =\n ((shift.end.getTime() - shift.start.getTime()) / windowSpan) * 100\n\n return (\n <Tooltip\n key={shift.id}\n content={\n <span className=\"flex flex-col\">\n <span>{shift.person}</span>\n <span className=\"opacity-70\">\n <Fmt type=\"date\" value={shift.start} format=\"ddd D MMM HH:mm\" locale={locale} />\n {' → '}\n <Fmt type=\"date\" value={shift.end} format=\"ddd D MMM HH:mm\" locale={locale} />\n </span>\n </span>\n }\n >\n <span\n tabIndex={0}\n style={{\n left: `${Math.max(left, 0)}%`,\n width: `${Math.min(width, 100 - Math.max(left, 0))}%`,\n backgroundColor: shift.color ?? paletteFor(shift.person).fill,\n // Name sits on the fill: read the paired ink, since\n // white on the amber entry fails contrast.\n color: shift.color ? 'white' : paletteFor(shift.person).ink,\n }}\n className=\"focus-visible:ring-ring absolute inset-y-0 flex items-center overflow-hidden rounded-md px-2 outline-none focus-visible:ring-2\"\n >\n <span className=\"truncate text-[10px] font-medium\">\n {shift.person}\n </span>\n </span>\n </Tooltip>\n )\n })}\n\n {reference >= start && reference <= end && (\n <span\n aria-hidden=\"true\"\n className=\"bg-foreground absolute inset-y-0 w-px\"\n style={{\n left: `${((reference.getTime() - windowStart) / windowSpan) * 100}%`,\n }}\n />\n )}\n </div>\n </div>\n ))}\n </div>\n </div>\n </div>\n )\n}\n\nexport { OnCallSchedule }\n"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|