@sntlr/registry-shell 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +200 -0
  3. package/dist/adapter/custom.d.ts +47 -0
  4. package/dist/adapter/custom.js +53 -0
  5. package/dist/adapter/custom.js.map +1 -0
  6. package/dist/adapter/default.d.ts +40 -0
  7. package/dist/adapter/default.js +202 -0
  8. package/dist/adapter/default.js.map +1 -0
  9. package/dist/cli/build.d.ts +1 -0
  10. package/dist/cli/build.js +31 -0
  11. package/dist/cli/build.js.map +1 -0
  12. package/dist/cli/dev.d.ts +1 -0
  13. package/dist/cli/dev.js +26 -0
  14. package/dist/cli/dev.js.map +1 -0
  15. package/dist/cli/index.d.ts +12 -0
  16. package/dist/cli/index.js +49 -0
  17. package/dist/cli/index.js.map +1 -0
  18. package/dist/cli/init.d.ts +1 -0
  19. package/dist/cli/init.js +70 -0
  20. package/dist/cli/init.js.map +1 -0
  21. package/dist/cli/shared.d.ts +33 -0
  22. package/dist/cli/shared.js +278 -0
  23. package/dist/cli/shared.js.map +1 -0
  24. package/dist/cli/start.d.ts +1 -0
  25. package/dist/cli/start.js +24 -0
  26. package/dist/cli/start.js.map +1 -0
  27. package/dist/config-loader.d.ts +49 -0
  28. package/dist/config-loader.js +140 -0
  29. package/dist/define-config.d.ts +188 -0
  30. package/dist/define-config.js +21 -0
  31. package/dist/index.d.ts +11 -0
  32. package/dist/index.js +9 -0
  33. package/package.json +124 -0
  34. package/src/adapter/custom.ts +90 -0
  35. package/src/adapter/default.ts +241 -0
  36. package/src/cli/build.ts +38 -0
  37. package/src/cli/dev.ts +38 -0
  38. package/src/cli/index.ts +52 -0
  39. package/src/cli/init.ts +76 -0
  40. package/src/cli/shared.ts +306 -0
  41. package/src/cli/start.ts +28 -0
  42. package/src/config-loader.ts +190 -0
  43. package/src/define-config.ts +206 -0
  44. package/src/index.ts +17 -0
  45. package/src/next-app/app/[...asset]/route.ts +81 -0
  46. package/src/next-app/app/_user-global.css +6 -0
  47. package/src/next-app/app/_user-sources.css +9 -0
  48. package/src/next-app/app/a11y/[name]/route.ts +19 -0
  49. package/src/next-app/app/api/search-index/route.ts +19 -0
  50. package/src/next-app/app/components/[name]/page.tsx +61 -0
  51. package/src/next-app/app/components/layout.tsx +18 -0
  52. package/src/next-app/app/docs/[slug]/page.tsx +53 -0
  53. package/src/next-app/app/docs/layout.tsx +18 -0
  54. package/src/next-app/app/globals.css +329 -0
  55. package/src/next-app/app/layout.tsx +102 -0
  56. package/src/next-app/app/page.tsx +9 -0
  57. package/src/next-app/app/preview-snapshot/[name]/page.tsx +20 -0
  58. package/src/next-app/app/preview-snapshot/layout.tsx +17 -0
  59. package/src/next-app/app/props/[name]/route.ts +19 -0
  60. package/src/next-app/app/r/[name]/route.ts +14 -0
  61. package/src/next-app/app/tests/[name]/route.ts +19 -0
  62. package/src/next-app/components/a11y-info.tsx +287 -0
  63. package/src/next-app/components/a11y-provider.tsx +39 -0
  64. package/src/next-app/components/component-breadcrumb.tsx +55 -0
  65. package/src/next-app/components/component-icon.tsx +140 -0
  66. package/src/next-app/components/component-preview.tsx +13 -0
  67. package/src/next-app/components/component-tabs.tsx +209 -0
  68. package/src/next-app/components/docs-toc.tsx +86 -0
  69. package/src/next-app/components/global-mobile-sidebar.tsx +35 -0
  70. package/src/next-app/components/header.tsx +188 -0
  71. package/src/next-app/components/heading-anchor.tsx +52 -0
  72. package/src/next-app/components/homepage-demo.tsx +180 -0
  73. package/src/next-app/components/locale-toggle.tsx +35 -0
  74. package/src/next-app/components/localized-mdx-client.tsx +14 -0
  75. package/src/next-app/components/localized-mdx.tsx +27 -0
  76. package/src/next-app/components/mobile-sidebar.tsx +22 -0
  77. package/src/next-app/components/nav-data-provider.tsx +37 -0
  78. package/src/next-app/components/navigation-progress.tsx +62 -0
  79. package/src/next-app/components/preview-canvas.tsx +368 -0
  80. package/src/next-app/components/preview-controls.tsx +94 -0
  81. package/src/next-app/components/preview-layout.tsx +218 -0
  82. package/src/next-app/components/props-table.tsx +134 -0
  83. package/src/next-app/components/resizable-preview.tsx +101 -0
  84. package/src/next-app/components/search.tsx +177 -0
  85. package/src/next-app/components/settings-modal.tsx +98 -0
  86. package/src/next-app/components/shell-ui/accordion.tsx +70 -0
  87. package/src/next-app/components/shell-ui/backdrop.tsx +29 -0
  88. package/src/next-app/components/shell-ui/badge.tsx +55 -0
  89. package/src/next-app/components/shell-ui/breadcrumb.tsx +120 -0
  90. package/src/next-app/components/shell-ui/button.tsx +64 -0
  91. package/src/next-app/components/shell-ui/card.tsx +127 -0
  92. package/src/next-app/components/shell-ui/checkbox.tsx +33 -0
  93. package/src/next-app/components/shell-ui/dialog.tsx +171 -0
  94. package/src/next-app/components/shell-ui/empty-state.tsx +66 -0
  95. package/src/next-app/components/shell-ui/input.tsx +27 -0
  96. package/src/next-app/components/shell-ui/kbd.tsx +30 -0
  97. package/src/next-app/components/shell-ui/label.tsx +25 -0
  98. package/src/next-app/components/shell-ui/select.tsx +204 -0
  99. package/src/next-app/components/shell-ui/separator.tsx +32 -0
  100. package/src/next-app/components/shell-ui/skeleton.tsx +18 -0
  101. package/src/next-app/components/shell-ui/table.tsx +124 -0
  102. package/src/next-app/components/shell-ui/tabs.tsx +102 -0
  103. package/src/next-app/components/shell-ui/toggle.tsx +56 -0
  104. package/src/next-app/components/sidebar-layout.tsx +37 -0
  105. package/src/next-app/components/sidebar-provider.tsx +75 -0
  106. package/src/next-app/components/sidebar.tsx +222 -0
  107. package/src/next-app/components/snapshot-preview.tsx +28 -0
  108. package/src/next-app/components/test-info.tsx +155 -0
  109. package/src/next-app/components/theme-provider.tsx +16 -0
  110. package/src/next-app/components/theme-toggle.tsx +21 -0
  111. package/src/next-app/components/translated-text.tsx +8 -0
  112. package/src/next-app/fallback/homepage.tsx +112 -0
  113. package/src/next-app/fallback/previews.ts +17 -0
  114. package/src/next-app/hooks/use-active-section.ts +23 -0
  115. package/src/next-app/hooks/use-controls.ts +72 -0
  116. package/src/next-app/hooks/use-mobile.ts +19 -0
  117. package/src/next-app/lib/branding.ts +52 -0
  118. package/src/next-app/lib/components-nav.ts +8 -0
  119. package/src/next-app/lib/docs.ts +16 -0
  120. package/src/next-app/lib/github.ts +38 -0
  121. package/src/next-app/lib/i18n.tsx +630 -0
  122. package/src/next-app/lib/locales.ts +17 -0
  123. package/src/next-app/lib/preview-loader.ts +7 -0
  124. package/src/next-app/lib/registry-adapter.ts +199 -0
  125. package/src/next-app/lib/utils.ts +6 -0
  126. package/src/next-app/next-env.d.ts +6 -0
  127. package/src/next-app/next.config.ts +101 -0
  128. package/src/next-app/postcss.config.mjs +7 -0
  129. package/src/next-app/public/favicon.ico +0 -0
  130. package/src/next-app/public/favicon_dark.svg +3 -0
  131. package/src/next-app/public/favicon_light.svg +3 -0
  132. package/src/next-app/registry.config.ts +50 -0
  133. package/src/next-app/tsconfig.json +29 -0
  134. package/src/next-app/user-aliases.d.ts +17 -0
@@ -0,0 +1,134 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import { FileCode } from "lucide-react"
5
+ import { Badge } from "@shell/components/shell-ui/badge"
6
+ import { EmptyState, EmptyStateIcon, EmptyStateDescription } from "@shell/components/shell-ui/empty-state"
7
+ import {
8
+ Table,
9
+ TableBody,
10
+ TableCell,
11
+ TableHead,
12
+ TableHeader,
13
+ TableRow,
14
+ } from "@shell/components/shell-ui/table"
15
+
16
+ interface PropDoc {
17
+ name: string
18
+ type: string
19
+ required: boolean
20
+ defaultValue: string | null
21
+ description: string
22
+ }
23
+
24
+ interface ComponentDoc {
25
+ displayName: string
26
+ description: string
27
+ props: PropDoc[]
28
+ }
29
+
30
+ export function PropsTable({ name }: { name: string }) {
31
+ const [docs, setDocs] = useState<ComponentDoc[] | null>(null)
32
+ const [error, setError] = useState(false)
33
+
34
+ useEffect(() => {
35
+ fetch(`/props/${name}.json`)
36
+ .then((r) => {
37
+ if (!r.ok) throw new Error()
38
+ return r.json()
39
+ })
40
+ .then(setDocs)
41
+ .catch(() => setError(true))
42
+ }, [name])
43
+
44
+ const isEmpty = error || (docs && docs.length === 0) || (docs && docs.every((c) => c.props.length === 0))
45
+
46
+ if (!docs && !error) return <p className="text-sm text-muted-foreground" role="status" aria-live="polite">Loading...</p>
47
+
48
+ if (isEmpty) return (
49
+ <EmptyState>
50
+ <EmptyStateIcon><FileCode /></EmptyStateIcon>
51
+ <EmptyStateDescription>No documented props for this component.</EmptyStateDescription>
52
+ </EmptyState>
53
+ )
54
+
55
+ if (!docs) return null
56
+
57
+ return (
58
+ <div className="space-y-8">
59
+ {docs.map((comp) => (
60
+ <div key={comp.displayName}>
61
+ {docs.length > 1 && (
62
+ <h4
63
+ id={`props-${comp.displayName.toLowerCase()}`}
64
+ className="text-base font-semibold mb-1"
65
+ >
66
+ {comp.displayName}
67
+ </h4>
68
+ )}
69
+ {comp.description && (
70
+ <p className="text-sm text-muted-foreground mb-3">
71
+ {comp.description}
72
+ </p>
73
+ )}
74
+ {comp.props.length > 0 ? (
75
+ <div className="rounded-lg border overflow-hidden">
76
+ <Table>
77
+ <TableHeader>
78
+ <TableRow>
79
+ <TableHead className="w-36">Prop</TableHead>
80
+ <TableHead className="w-48">Type</TableHead>
81
+ <TableHead className="w-24">Default</TableHead>
82
+ <TableHead>Description</TableHead>
83
+ </TableRow>
84
+ </TableHeader>
85
+ <TableBody>
86
+ {comp.props.map((prop) => (
87
+ <TableRow key={prop.name}>
88
+ <TableCell>
89
+ <code className="text-xs font-mono">{prop.name}</code>
90
+ {prop.required && (
91
+ <Badge
92
+ variant="destructive"
93
+ className="ml-1.5 text-[10px] px-1 py-0"
94
+ >
95
+ required
96
+ </Badge>
97
+ )}
98
+ </TableCell>
99
+ <TableCell>
100
+ <code className="text-xs font-mono text-muted-foreground">
101
+ {prop.type}
102
+ </code>
103
+ </TableCell>
104
+ <TableCell>
105
+ {prop.defaultValue ? (
106
+ <code className="text-xs font-mono">
107
+ {prop.defaultValue}
108
+ </code>
109
+ ) : (
110
+ <span className="text-xs text-muted-foreground">
111
+
112
+ </span>
113
+ )}
114
+ </TableCell>
115
+ <TableCell className="text-sm">
116
+ {prop.description || (
117
+ <span className="text-muted-foreground">—</span>
118
+ )}
119
+ </TableCell>
120
+ </TableRow>
121
+ ))}
122
+ </TableBody>
123
+ </Table>
124
+ </div>
125
+ ) : (
126
+ <p className="text-sm text-muted-foreground">
127
+ No documented props.
128
+ </p>
129
+ )}
130
+ </div>
131
+ ))}
132
+ </div>
133
+ )
134
+ }
@@ -0,0 +1,101 @@
1
+ "use client"
2
+
3
+ import { useCallback, useRef, useState, useEffect } from "react"
4
+
5
+ const STORAGE_KEY = "preview-height"
6
+ const DEFAULT_HEIGHT_DESKTOP = 384
7
+ const DEFAULT_HEIGHT_MOBILE = 600
8
+ const MIN_HEIGHT = 200
9
+ const MAX_HEIGHT = 1000
10
+
11
+ function getDefaultHeight(): number {
12
+ if (typeof window === "undefined") return DEFAULT_HEIGHT_DESKTOP
13
+ return window.matchMedia("(max-width: 767px)").matches
14
+ ? DEFAULT_HEIGHT_MOBILE
15
+ : DEFAULT_HEIGHT_DESKTOP
16
+ }
17
+
18
+ function getStoredHeight(): number {
19
+ if (typeof window === "undefined") return DEFAULT_HEIGHT_DESKTOP
20
+ const stored = sessionStorage.getItem(STORAGE_KEY)
21
+ if (stored) {
22
+ const n = Number(stored)
23
+ if (!isNaN(n)) return Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, n))
24
+ }
25
+ return getDefaultHeight()
26
+ }
27
+
28
+ export function ResizablePreview({ children }: { children: React.ReactNode }) {
29
+ const [height, setHeight] = useState(getStoredHeight)
30
+ const dragging = useRef(false)
31
+ const startY = useRef(0)
32
+ const startH = useRef(0)
33
+
34
+ useEffect(() => {
35
+ sessionStorage.setItem(STORAGE_KEY, String(height))
36
+ }, [height])
37
+
38
+ const onMouseDown = useCallback((e: React.MouseEvent) => {
39
+ e.preventDefault()
40
+ dragging.current = true
41
+ startY.current = e.clientY
42
+ startH.current = height
43
+ document.body.style.cursor = "row-resize"
44
+ document.body.style.userSelect = "none"
45
+
46
+ function onMouseMove(ev: MouseEvent) {
47
+ if (!dragging.current) return
48
+ const newH = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, startH.current + (ev.clientY - startY.current)))
49
+ setHeight(newH)
50
+ }
51
+
52
+ function onMouseUp() {
53
+ dragging.current = false
54
+ document.body.style.cursor = ""
55
+ document.body.style.userSelect = ""
56
+ document.removeEventListener("mousemove", onMouseMove)
57
+ document.removeEventListener("mouseup", onMouseUp)
58
+ }
59
+
60
+ document.addEventListener("mousemove", onMouseMove)
61
+ document.addEventListener("mouseup", onMouseUp)
62
+ }, [height])
63
+
64
+ const onTouchStart = useCallback((e: React.TouchEvent) => {
65
+ if (e.touches.length !== 1) return
66
+ dragging.current = true
67
+ startY.current = e.touches[0].clientY
68
+ startH.current = height
69
+
70
+ function onTouchMove(ev: TouchEvent) {
71
+ if (!dragging.current || ev.touches.length !== 1) return
72
+ ev.preventDefault()
73
+ const newH = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, startH.current + (ev.touches[0].clientY - startY.current)))
74
+ setHeight(newH)
75
+ }
76
+
77
+ function onTouchEnd() {
78
+ dragging.current = false
79
+ document.removeEventListener("touchmove", onTouchMove)
80
+ document.removeEventListener("touchend", onTouchEnd)
81
+ }
82
+
83
+ document.addEventListener("touchmove", onTouchMove, { passive: false })
84
+ document.addEventListener("touchend", onTouchEnd)
85
+ }, [height])
86
+
87
+ return (
88
+ <div>
89
+ <div style={{ height }}>
90
+ {children}
91
+ </div>
92
+ <div
93
+ className="h-3 md:h-2 cursor-row-resize flex items-center justify-center hover:bg-muted/50 active:bg-muted/50 transition-colors group touch-none"
94
+ onMouseDown={onMouseDown}
95
+ onTouchStart={onTouchStart}
96
+ >
97
+ <div className="w-8 h-0.5 rounded-full bg-border group-hover:bg-muted-foreground transition-colors" />
98
+ </div>
99
+ </div>
100
+ )
101
+ }
@@ -0,0 +1,177 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useState } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import { BookOpen, Component, Search, X } from "lucide-react"
6
+ import { Command } from "cmdk"
7
+ import { Button } from "@shell/components/shell-ui/button"
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogTitle,
12
+ } from "@shell/components/shell-ui/dialog"
13
+ import { useTranslations } from "@shell/lib/i18n"
14
+
15
+ export interface SearchItem {
16
+ label: string
17
+ href: string
18
+ group: string
19
+ }
20
+
21
+ // Module-level cache — survives re-renders, shared across mounts
22
+ let cachedItems: SearchItem[] | null = null
23
+ let fetchPromise: Promise<void> | null = null
24
+
25
+ function preloadSearchIndex() {
26
+ if (cachedItems || fetchPromise) return
27
+ fetchPromise = fetch("/api/search-index")
28
+ .then((r) => r.json())
29
+ .then((data) => { cachedItems = data })
30
+ .catch(() => {})
31
+ }
32
+
33
+ export function SearchTrigger() {
34
+ return <SearchDialog />
35
+ }
36
+
37
+ function SearchDialog() {
38
+ const router = useRouter()
39
+ const t = useTranslations()
40
+ const [open, setOpen] = useState(false)
41
+ const [query, setQuery] = useState("")
42
+ const [items, setItems] = useState<SearchItem[]>([])
43
+ const [loading, setLoading] = useState(false)
44
+
45
+ // Preload search index on mount (fires on page load)
46
+ useEffect(() => {
47
+ preloadSearchIndex()
48
+ }, [])
49
+
50
+ useEffect(() => {
51
+ const down = (e: KeyboardEvent) => {
52
+ if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
53
+ e.preventDefault()
54
+ setOpen((o) => !o)
55
+ }
56
+ }
57
+ document.addEventListener("keydown", down)
58
+ return () => document.removeEventListener("keydown", down)
59
+ }, [])
60
+
61
+ // When dialog opens, use cached data or wait for in-flight fetch
62
+ useEffect(() => {
63
+ if (!open || items.length > 0) return
64
+ if (cachedItems) {
65
+ setItems(cachedItems)
66
+ return
67
+ }
68
+ setLoading(true)
69
+ const waitForCache = async () => {
70
+ if (fetchPromise) await fetchPromise
71
+ if (cachedItems) setItems(cachedItems)
72
+ setLoading(false)
73
+ }
74
+ waitForCache()
75
+ }, [open, items.length])
76
+
77
+ const onSelect = useCallback(
78
+ (href: string) => {
79
+ setOpen(false)
80
+ router.push(href)
81
+ },
82
+ [router]
83
+ )
84
+
85
+ const groups = items.reduce<Record<string, SearchItem[]>>((acc, item) => {
86
+ ;(acc[item.group] ??= []).push(item)
87
+ return acc
88
+ }, {})
89
+
90
+ return (
91
+ <>
92
+ {/* Desktop: full search bar */}
93
+ <Button
94
+ variant="outline"
95
+ size="sm"
96
+ className="hidden md:inline-flex gap-2 text-muted-foreground font-normal w-56 justify-start"
97
+ onClick={() => setOpen(true)}
98
+ >
99
+ <Search className="size-4" />
100
+ <span>{t("header.search")}</span>
101
+ <kbd className="ml-auto pointer-events-none hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground sm:flex">
102
+ <span className="text-xs">&#8984;</span>K
103
+ </kbd>
104
+ </Button>
105
+ {/* Mobile: icon only */}
106
+ <Button
107
+ variant="ghost"
108
+ size="icon"
109
+ className="md:hidden"
110
+ onClick={() => setOpen(true)}
111
+ aria-label="Search"
112
+ >
113
+ <Search className="size-4" />
114
+ </Button>
115
+ <Dialog open={open} onOpenChange={setOpen}>
116
+ <DialogContent
117
+ className="overflow-hidden p-0 sm:max-w-lg max-md:max-w-full! max-md:h-dvh max-md:rounded-none max-md:border-0 max-md:top-0 max-md:translate-y-0"
118
+ showCloseButton={false}
119
+ >
120
+ <DialogTitle className="sr-only">Search</DialogTitle>
121
+ <Command
122
+ className="flex flex-col h-full [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium"
123
+ loop
124
+ >
125
+ <div className="flex items-center border-b px-3">
126
+ <Search className="mr-2 size-4 shrink-0 opacity-50" />
127
+
128
+ <Command.Input
129
+ value={query}
130
+ onValueChange={setQuery}
131
+ placeholder={t("search.placeholder")}
132
+ className="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
133
+ />
134
+ <button
135
+ onClick={() => setOpen(false)}
136
+ className="md:hidden p-1 rounded-md text-muted-foreground hover:text-foreground cursor-pointer"
137
+ aria-label="Close"
138
+ >
139
+ <X className="size-4" />
140
+ </button>
141
+ </div>
142
+ <Command.List className="max-h-80 max-md:max-h-none max-md:flex-1 overflow-y-auto p-1" aria-busy={loading} aria-live="polite">
143
+ {loading ? (
144
+ <div className="py-6 text-center text-sm text-muted-foreground" role="status">
145
+ {t("a11y.loading")}
146
+ </div>
147
+ ) : (
148
+ <Command.Empty className="py-6 text-center text-sm text-muted-foreground">
149
+ {t("search.noResults")}
150
+ </Command.Empty>
151
+ )}
152
+ {Object.entries(groups).map(([group, groupItems]) => {
153
+ const Icon =
154
+ group === "Documentation" ? BookOpen : Component
155
+ return (
156
+ <Command.Group key={group} heading={group}>
157
+ {groupItems.map((item) => (
158
+ <Command.Item
159
+ key={item.href}
160
+ value={`${item.group} ${item.label}`}
161
+ onSelect={() => onSelect(item.href)}
162
+ className="relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground"
163
+ >
164
+ <Icon className="size-4 shrink-0 text-muted-foreground" />
165
+ {item.label}
166
+ </Command.Item>
167
+ ))}
168
+ </Command.Group>
169
+ )
170
+ })}
171
+ </Command.List>
172
+ </Command>
173
+ </DialogContent>
174
+ </Dialog>
175
+ </>
176
+ )
177
+ }
@@ -0,0 +1,98 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { Settings, Sun, Moon, Monitor, Globe } from "lucide-react"
5
+ import { useTheme } from "next-themes"
6
+ import { Button } from "@shell/components/shell-ui/button"
7
+ import { Separator } from "@shell/components/shell-ui/separator"
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ DialogTrigger,
14
+ } from "@shell/components/shell-ui/dialog"
15
+ import { useLocale, useTranslations, type Locale } from "@shell/lib/i18n"
16
+
17
+ export function SettingsButton() {
18
+ const [open, setOpen] = useState(false)
19
+ const { theme, setTheme } = useTheme()
20
+ const { locale, setLocale } = useLocale()
21
+ const t = useTranslations()
22
+
23
+ const themes = [
24
+ { id: "light" as const, label: t("settings.light"), icon: Sun },
25
+ { id: "dark" as const, label: t("settings.dark"), icon: Moon },
26
+ { id: "system" as const, label: t("settings.system"), icon: Monitor },
27
+ ]
28
+
29
+ return (
30
+ <Dialog open={open} onOpenChange={setOpen}>
31
+ <DialogTrigger asChild>
32
+ <Button
33
+ variant="ghost"
34
+ size="icon"
35
+ aria-label={t("settings.title")}
36
+ className="md:hidden"
37
+ >
38
+ <Settings className="size-4" />
39
+ </Button>
40
+ </DialogTrigger>
41
+ <DialogContent className="max-md:max-w-full! max-md:h-full max-md:rounded-none max-md:border-0">
42
+ <DialogHeader>
43
+ <DialogTitle>{t("settings.title")}</DialogTitle>
44
+ </DialogHeader>
45
+
46
+ <div className="space-y-6">
47
+ {/* Theme */}
48
+ <div className="space-y-3">
49
+ <h3 className="text-sm font-semibold flex items-center gap-2">
50
+ <Sun className="size-4" />
51
+ {t("settings.theme")}
52
+ </h3>
53
+ <div className="flex gap-2">
54
+ {themes.map((opt) => (
55
+ <Button
56
+ key={opt.id}
57
+ variant={theme === opt.id ? "default" : "outline"}
58
+ size="sm"
59
+ onClick={() => setTheme(opt.id)}
60
+ className="flex-1"
61
+ >
62
+ <opt.icon className="size-3.5 mr-1.5" />
63
+ {opt.label}
64
+ </Button>
65
+ ))}
66
+ </div>
67
+ </div>
68
+
69
+ <Separator />
70
+
71
+ {/* Language */}
72
+ <div className="space-y-3">
73
+ <h3 className="text-sm font-semibold flex items-center gap-2">
74
+ <Globe className="size-4" />
75
+ {t("settings.language")}
76
+ </h3>
77
+ <div className="flex gap-2">
78
+ {([
79
+ { id: "en" as Locale, label: "English" },
80
+ { id: "fr" as Locale, label: "Français" },
81
+ ]).map((lang) => (
82
+ <Button
83
+ key={lang.id}
84
+ variant={locale === lang.id ? "default" : "outline"}
85
+ size="sm"
86
+ onClick={() => setLocale(lang.id)}
87
+ className="flex-1"
88
+ >
89
+ {lang.label}
90
+ </Button>
91
+ ))}
92
+ </div>
93
+ </div>
94
+ </div>
95
+ </DialogContent>
96
+ </Dialog>
97
+ )
98
+ }
@@ -0,0 +1,70 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { ChevronDownIcon } from "lucide-react"
5
+ import { Accordion as AccordionPrimitive } from "radix-ui"
6
+
7
+ import { cn } from "@shell/lib/utils"
8
+
9
+ /** Root container for a set of collapsible accordion sections. */
10
+ function Accordion({
11
+ ...props
12
+ }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
13
+ return <AccordionPrimitive.Root data-slot="accordion" {...props} />
14
+ }
15
+
16
+ /** A single collapsible section within an accordion. */
17
+ function AccordionItem({
18
+ className,
19
+ ...props
20
+ }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
21
+ return (
22
+ <AccordionPrimitive.Item
23
+ data-slot="accordion-item"
24
+ className={cn("border-b last:border-b-0", className)}
25
+ {...props}
26
+ />
27
+ )
28
+ }
29
+
30
+ /** Clickable header that toggles the visibility of its associated accordion content. */
31
+ function AccordionTrigger({
32
+ className,
33
+ children,
34
+ ...props
35
+ }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
36
+ return (
37
+ <AccordionPrimitive.Header className="flex">
38
+ <AccordionPrimitive.Trigger
39
+ data-slot="accordion-trigger"
40
+ className={cn(
41
+ "flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
42
+ className
43
+ )}
44
+ {...props}
45
+ >
46
+ {children}
47
+ <ChevronDownIcon className="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" />
48
+ </AccordionPrimitive.Trigger>
49
+ </AccordionPrimitive.Header>
50
+ )
51
+ }
52
+
53
+ /** Animated collapsible content area revealed when its accordion item is expanded. */
54
+ function AccordionContent({
55
+ className,
56
+ children,
57
+ ...props
58
+ }: React.ComponentProps<typeof AccordionPrimitive.Content>) {
59
+ return (
60
+ <AccordionPrimitive.Content
61
+ data-slot="accordion-content"
62
+ className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
63
+ {...props}
64
+ >
65
+ <div className={cn("pt-0 pb-4", className)}>{children}</div>
66
+ </AccordionPrimitive.Content>
67
+ )
68
+ }
69
+
70
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
@@ -0,0 +1,29 @@
1
+ "use client"
2
+
3
+ import { cn } from "@shell/lib/utils"
4
+
5
+ /** A fixed overlay backdrop for modals, drawers, and floating panels. */
6
+ function Backdrop({
7
+ /** Additional CSS classes. */
8
+ className,
9
+ /** Whether the backdrop starts below the header (top: 3.5rem). */
10
+ belowHeader = false,
11
+ ...props
12
+ }: React.ComponentProps<"div"> & {
13
+ /** Whether the backdrop starts below the header. */
14
+ belowHeader?: boolean
15
+ }) {
16
+ return (
17
+ <div
18
+ data-slot="backdrop"
19
+ className={cn(
20
+ "fixed inset-x-0 bottom-0 z-50 bg-black/40 dark:bg-black/60 transition-opacity duration-150 motion-reduce:transition-none",
21
+ belowHeader ? "top-14" : "top-0",
22
+ className
23
+ )}
24
+ {...props}
25
+ />
26
+ )
27
+ }
28
+
29
+ export { Backdrop }
@@ -0,0 +1,55 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+ import { Slot } from "radix-ui"
4
+
5
+ import { cn } from "@shell/lib/utils"
6
+
7
+ const badgeVariants = cva(
8
+ "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
13
+ secondary:
14
+ "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
15
+ destructive:
16
+ "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
17
+ outline:
18
+ "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
19
+ ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
20
+ link: "text-primary underline-offset-4 [a&]:hover:underline",
21
+ },
22
+ },
23
+ defaultVariants: {
24
+ variant: "default",
25
+ },
26
+ }
27
+ )
28
+
29
+ function Badge({
30
+ className,
31
+ /** The visual style variant of the badge. */
32
+ variant = "default",
33
+ /** Whether to render as a child element using Radix Slot. */
34
+ asChild = false,
35
+ ...props
36
+ }: React.ComponentProps<"span"> &
37
+ VariantProps<typeof badgeVariants> & {
38
+ /** The visual style variant of the badge. */
39
+ variant?: "default" | "secondary" | "destructive" | "outline"
40
+ /** Whether to render as a child element using Radix Slot. */
41
+ asChild?: boolean
42
+ }) {
43
+ const Comp = asChild ? Slot.Root : "span"
44
+
45
+ return (
46
+ <Comp
47
+ data-slot="badge"
48
+ data-variant={variant}
49
+ className={cn(badgeVariants({ variant }), className)}
50
+ {...props}
51
+ />
52
+ )
53
+ }
54
+
55
+ export { Badge, badgeVariants }