create-vexcms 0.0.8 → 0.0.10

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 (63) hide show
  1. package/package.json +1 -1
  2. package/templates/base-nextjs/convex/vex/globals.ts +82 -0
  3. package/templates/base-nextjs/convex/vex/versions.ts +66 -6
  4. package/templates/base-nextjs/package.json +7 -7
  5. package/templates/base-nextjs/src/app/admin/layout.tsx +29 -11
  6. package/templates/base-nextjs/src/db/constants/auth.ts +1 -3
  7. package/templates/base-nextjs/src/vexcms/access.ts +29 -13
  8. package/templates/base-nextjs/src/vexcms/collections/users.ts +2 -2
  9. package/templates/base-nextjs/vex.config.ts +2 -0
  10. package/templates/marketing-site/convex/footers.ts +14 -0
  11. package/templates/marketing-site/convex/headers.ts +14 -0
  12. package/templates/marketing-site/convex/siteSettings.ts +14 -0
  13. package/templates/marketing-site/convex/theme.ts +75 -0
  14. package/templates/marketing-site/public/favicons/favicon.ico +0 -0
  15. package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +74 -0
  16. package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +19 -51
  17. package/templates/marketing-site/src/app/(frontend)/layout.tsx +37 -0
  18. package/templates/marketing-site/src/app/(frontend)/page.tsx +24 -0
  19. package/templates/marketing-site/src/app/(frontend)/preview/[slug]/PreviewPageContent.tsx +55 -0
  20. package/templates/marketing-site/src/app/(frontend)/preview/[slug]/page.tsx +19 -63
  21. package/templates/marketing-site/src/app/(frontend)/preview/layout.tsx +18 -0
  22. package/templates/marketing-site/src/components/SiteFooter.tsx +28 -0
  23. package/templates/marketing-site/src/components/SiteHeader.tsx +28 -0
  24. package/templates/marketing-site/src/components/ThemeInjector.tsx +128 -0
  25. package/templates/marketing-site/src/components/ThemeStyle.tsx +104 -0
  26. package/templates/marketing-site/src/components/admin/IconPickerField.tsx +134 -0
  27. package/templates/marketing-site/src/components/admin/ThemeImport.tsx +174 -0
  28. package/templates/marketing-site/src/components/admin/ThemeImportField.tsx +33 -0
  29. package/templates/marketing-site/src/components/motion-primitives/animated-group.tsx +138 -0
  30. package/templates/marketing-site/src/components/motion-primitives/text-effect.tsx +292 -0
  31. package/templates/marketing-site/src/components/ui/accordion.tsx +74 -0
  32. package/templates/marketing-site/src/lib/colorConvert.ts +171 -0
  33. package/templates/marketing-site/src/lib/metadata.ts +104 -0
  34. package/templates/marketing-site/src/vexcms/access.ts +28 -0
  35. package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +36 -0
  36. package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +46 -0
  37. package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +70 -0
  38. package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +71 -0
  39. package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +61 -0
  40. package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +73 -0
  41. package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +65 -0
  42. package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +71 -0
  43. package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +65 -0
  44. package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +174 -0
  45. package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +52 -0
  46. package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +152 -0
  47. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +68 -0
  48. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +74 -0
  49. package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +154 -0
  50. package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +122 -0
  51. package/templates/marketing-site/src/vexcms/blocks/config.ts +40 -0
  52. package/templates/marketing-site/src/vexcms/blocks/constants.ts +9 -0
  53. package/templates/marketing-site/src/vexcms/blocks/index.ts +39 -0
  54. package/templates/marketing-site/src/vexcms/collections/footers.ts +11 -8
  55. package/templates/marketing-site/src/vexcms/collections/headers.ts +10 -14
  56. package/templates/marketing-site/src/vexcms/collections/index.ts +0 -1
  57. package/templates/marketing-site/src/vexcms/collections/pages.ts +30 -4
  58. package/templates/marketing-site/src/vexcms/collections/themes.ts +107 -14
  59. package/templates/marketing-site/src/vexcms/globals/index.ts +1 -0
  60. package/templates/marketing-site/src/vexcms/globals/siteSettings.ts +57 -0
  61. package/templates/marketing-site/vex.config.ts +5 -5
  62. package/templates/base-nextjs/src/auth/permissions.ts +0 -102
  63. package/templates/marketing-site/src/vexcms/collections/site_settings.ts +0 -31
@@ -0,0 +1,174 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+
5
+ import { parseThemeCSS } from "~/lib/colorConvert"
6
+
7
+ /**
8
+ * CSS variable name → form field path mapping.
9
+ * Maps CSS custom property names (from :root / .dark blocks)
10
+ * to the field paths in the themes collection form.
11
+ *
12
+ * Tabs with slugs create nested paths: "light.background", "dark.background"
13
+ * This maps the CSS var name to the camelCase field name used in themeColorFields().
14
+ */
15
+ const CSS_VAR_TO_FIELD: Record<string, string> = {
16
+ background: "background",
17
+ foreground: "foreground",
18
+ card: "card",
19
+ "card-foreground": "cardForeground",
20
+ popover: "popover",
21
+ "popover-foreground": "popoverForeground",
22
+ primary: "primary",
23
+ "primary-foreground": "primaryForeground",
24
+ secondary: "secondary",
25
+ "secondary-foreground": "secondaryForeground",
26
+ muted: "muted",
27
+ "muted-foreground": "mutedForeground",
28
+ accent: "accent",
29
+ "accent-foreground": "accentForeground",
30
+ destructive: "destructive",
31
+ "destructive-foreground": "destructiveForeground",
32
+ border: "border",
33
+ input: "input",
34
+ ring: "ring",
35
+ "chart-1": "chart1",
36
+ "chart-2": "chart2",
37
+ "chart-3": "chart3",
38
+ "chart-4": "chart4",
39
+ "chart-5": "chart5",
40
+ sidebar: "sidebar",
41
+ "sidebar-foreground": "sidebarForeground",
42
+ "sidebar-primary": "sidebarPrimary",
43
+ "sidebar-primary-foreground": "sidebarPrimaryForeground",
44
+ "sidebar-accent": "sidebarAccent",
45
+ "sidebar-accent-foreground": "sidebarAccentForeground",
46
+ "sidebar-border": "sidebarBorder",
47
+ "sidebar-ring": "sidebarRing",
48
+ }
49
+
50
+ interface ThemeImportProps {
51
+ /** Callback to set a form field value by path. e.g., setFieldValue("light.background", "#fff") */
52
+ onImport: (updates: Record<string, string>) => void
53
+ }
54
+
55
+ /**
56
+ * Theme import component — paste CSS from tweakcn/shadcn to populate color fields.
57
+ *
58
+ * Parses :root { } and .dark { } blocks from the CSS, maps variable names to
59
+ * form field paths, and calls onImport with all matched field updates.
60
+ *
61
+ * Designed to work with any CSS file — only updates fields that match known
62
+ * CSS variable names. Unmatched variables are silently ignored.
63
+ */
64
+ export function ThemeImport({ onImport }: ThemeImportProps) {
65
+ const [css, setCss] = useState("")
66
+ const [isExpanded, setIsExpanded] = useState(false)
67
+ const [importResult, setImportResult] = useState<string | null>(null)
68
+
69
+ const handleImport = () => {
70
+ if (!css.trim()) return
71
+
72
+ const parsed = parseThemeCSS({ css })
73
+ const updates: Record<string, string> = {}
74
+ let count = 0
75
+
76
+ // Map light colors to light.{fieldName} paths
77
+ for (const [cssVar, value] of Object.entries(parsed.light)) {
78
+ const fieldName = CSS_VAR_TO_FIELD[cssVar]
79
+ if (fieldName) {
80
+ updates[`light.${fieldName}`] = value
81
+ count++
82
+ }
83
+ }
84
+
85
+ // Map dark colors to dark.{fieldName} paths
86
+ for (const [cssVar, value] of Object.entries(parsed.dark)) {
87
+ const fieldName = CSS_VAR_TO_FIELD[cssVar]
88
+ if (fieldName) {
89
+ updates[`dark.${fieldName}`] = value
90
+ count++
91
+ }
92
+ }
93
+
94
+ if (count > 0) {
95
+ onImport(updates)
96
+ setImportResult(`Imported ${count} color values`)
97
+ setCss("")
98
+ setTimeout(() => setImportResult(null), 3000)
99
+ } else {
100
+ setImportResult("No matching CSS variables found")
101
+ setTimeout(() => setImportResult(null), 3000)
102
+ }
103
+ }
104
+
105
+ return (
106
+ <div className="rounded-lg border bg-card p-4 mb-6">
107
+ <button
108
+ type="button"
109
+ onClick={() => setIsExpanded(!isExpanded)}
110
+ className="flex items-center gap-2 text-sm font-medium w-full text-left"
111
+ >
112
+ <span className={`transition-transform ${isExpanded ? "rotate-90" : ""}`}>▶</span>
113
+ Import Theme from CSS
114
+ </button>
115
+
116
+ {isExpanded && (
117
+ <div className="mt-3 space-y-3">
118
+ <p className="text-xs text-muted-foreground">
119
+ Paste CSS from{" "}
120
+ <a
121
+ href="https://tweakcn.com"
122
+ target="_blank"
123
+ rel="noopener noreferrer"
124
+ className="underline"
125
+ >
126
+ tweakcn
127
+ </a>
128
+ {" "}or{" "}
129
+ <a
130
+ href="https://ui.shadcn.com/themes"
131
+ target="_blank"
132
+ rel="noopener noreferrer"
133
+ className="underline"
134
+ >
135
+ shadcn/ui themes
136
+ </a>
137
+ . The importer will match CSS variable names to color fields and fill them in.
138
+ </p>
139
+
140
+ <textarea
141
+ value={css}
142
+ onChange={(e) => setCss(e.target.value)}
143
+ placeholder={`:root {
144
+ --background: #ffffff;
145
+ --foreground: #0a0a0a;
146
+ --primary: #171717;
147
+ ...
148
+ }
149
+
150
+ .dark {
151
+ --background: #0a0a0a;
152
+ ...
153
+ }`}
154
+ className="w-full h-40 rounded-md border bg-background px-3 py-2 text-xs font-mono resize-y"
155
+ />
156
+
157
+ <div className="flex items-center gap-3">
158
+ <button
159
+ type="button"
160
+ onClick={handleImport}
161
+ disabled={!css.trim()}
162
+ className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
163
+ >
164
+ Import
165
+ </button>
166
+ {importResult && (
167
+ <span className="text-xs text-muted-foreground">{importResult}</span>
168
+ )}
169
+ </div>
170
+ </div>
171
+ )}
172
+ </div>
173
+ )
174
+ }
@@ -0,0 +1,33 @@
1
+ "use client"
2
+
3
+ import { useVexFormContext } from "@vexcms/ui"
4
+
5
+ import { ThemeImport } from "./ThemeImport"
6
+
7
+ /**
8
+ * Custom admin field component that renders the theme CSS import UI.
9
+ * Uses the VexForm context to programmatically set color field values
10
+ * when CSS is imported.
11
+ *
12
+ * Register on a ui() field in the themes collection:
13
+ * ```ts
14
+ * importTheme: ui({ admin: { components: { Field: ThemeImportField } } })
15
+ * ```
16
+ */
17
+ export default function ThemeImportField() {
18
+ const { form } = useVexFormContext()
19
+
20
+ const handleImport = (updates: Record<string, string>) => {
21
+ for (const [path, value] of Object.entries(updates)) {
22
+ // Path is like "light.background" or "dark.foreground"
23
+ // Tabs expand to top-level keys, so the form field path is just the path directly
24
+ try {
25
+ form.setFieldValue(path as any, value)
26
+ } catch {
27
+ // Field might not exist in this form — silently skip
28
+ }
29
+ }
30
+ }
31
+
32
+ return <ThemeImport onImport={handleImport} />
33
+ }
@@ -0,0 +1,138 @@
1
+ "use client"
2
+
3
+ import { motion, type Variants } from "motion/react"
4
+ import React, { type ComponentPropsWithRef } from "react"
5
+
6
+ export type PresetType =
7
+ | "blur"
8
+ | "blur-slide"
9
+ | "bounce"
10
+ | "fade"
11
+ | "flip"
12
+ | "rotate"
13
+ | "scale"
14
+ | "slide"
15
+ | "swing"
16
+ | "zoom"
17
+
18
+ export type AnimatedGroupProps = ComponentPropsWithRef<"div"> & {
19
+ as?: React.ElementType
20
+ asChild?: React.ElementType
21
+ className?: string
22
+ preset?: PresetType
23
+ variants?: {
24
+ container?: Variants
25
+ item?: Variants
26
+ }
27
+ }
28
+
29
+ const defaultContainerVariants: Variants = {
30
+ visible: {
31
+ transition: {
32
+ staggerChildren: 0.1,
33
+ },
34
+ },
35
+ }
36
+
37
+ const defaultItemVariants: Variants = {
38
+ hidden: { opacity: 0 },
39
+ visible: { opacity: 1 },
40
+ }
41
+
42
+ const presetVariants: Record<PresetType, Variants> = {
43
+ "blur-slide": {
44
+ hidden: { filter: "blur(4px)", y: 20 },
45
+ visible: { filter: "blur(0px)", y: 0 },
46
+ },
47
+ slide: {
48
+ hidden: { y: 20 },
49
+ visible: { y: 0 },
50
+ },
51
+ blur: {
52
+ hidden: { filter: "blur(4px)" },
53
+ visible: { filter: "blur(0px)" },
54
+ },
55
+ bounce: {
56
+ hidden: { y: -50 },
57
+ visible: {
58
+ transition: { type: "spring", damping: 10, stiffness: 400 },
59
+ y: 0,
60
+ },
61
+ },
62
+ fade: {},
63
+ flip: {
64
+ hidden: { rotateX: -90 },
65
+ visible: {
66
+ rotateX: 0,
67
+ transition: { type: "spring", damping: 20, stiffness: 300 },
68
+ },
69
+ },
70
+ rotate: {
71
+ hidden: { rotate: -180 },
72
+ visible: {
73
+ rotate: 0,
74
+ transition: { type: "spring", damping: 15, stiffness: 200 },
75
+ },
76
+ },
77
+ scale: {
78
+ hidden: { scale: 0.8 },
79
+ visible: { scale: 1 },
80
+ },
81
+ swing: {
82
+ hidden: { rotate: -10 },
83
+ visible: {
84
+ rotate: 0,
85
+ transition: { type: "spring", damping: 8, stiffness: 300 },
86
+ },
87
+ },
88
+ zoom: {
89
+ hidden: { scale: 0.5 },
90
+ visible: {
91
+ scale: 1,
92
+ transition: { type: "spring", damping: 20, stiffness: 300 },
93
+ },
94
+ },
95
+ }
96
+
97
+ const addDefaultVariants = (variants: Variants) => ({
98
+ hidden: { ...defaultItemVariants.hidden, ...variants.hidden },
99
+ visible: { ...defaultItemVariants.visible, ...variants.visible },
100
+ })
101
+
102
+ function AnimatedGroup({
103
+ as = "div",
104
+ asChild = "div",
105
+ children,
106
+ className,
107
+ preset,
108
+ variants,
109
+ ...divProps
110
+ }: AnimatedGroupProps) {
111
+ const selectedVariants = {
112
+ container: addDefaultVariants(defaultContainerVariants),
113
+ item: addDefaultVariants(preset ? presetVariants[preset] : {}),
114
+ }
115
+ const containerVariants = variants?.container ?? selectedVariants.container
116
+ const itemVariants = variants?.item ?? selectedVariants.item
117
+
118
+ const MotionComponent = motion(as)
119
+ const MotionChild = motion(asChild)
120
+
121
+ return (
122
+ <MotionComponent
123
+ animate="visible"
124
+ className={className}
125
+ initial="hidden"
126
+ variants={containerVariants}
127
+ {...divProps}
128
+ >
129
+ {React.Children.map(children, (child, index) => (
130
+ <MotionChild key={index} variants={itemVariants}>
131
+ {child}
132
+ </MotionChild>
133
+ ))}
134
+ </MotionComponent>
135
+ )
136
+ }
137
+
138
+ export { AnimatedGroup }
@@ -0,0 +1,292 @@
1
+ "use client"
2
+
3
+ import {
4
+ AnimatePresence,
5
+ motion,
6
+ type TargetAndTransition,
7
+ type Transition,
8
+ type Variant,
9
+ type Variants,
10
+ } from "motion/react"
11
+ import React from "react"
12
+
13
+ import { cn } from "~/lib/utils"
14
+
15
+ export type PerType = "char" | "line" | "word"
16
+ export type PresetType = "blur" | "fade" | "fade-in-blur" | "scale" | "slide"
17
+
18
+ export type TextEffectProps = {
19
+ as?: keyof React.JSX.IntrinsicElements
20
+ children: string
21
+ className?: string
22
+ containerTransition?: Transition
23
+ delay?: number
24
+ onAnimationComplete?: () => void
25
+ onAnimationStart?: () => void
26
+ per?: PerType
27
+ preset?: PresetType
28
+ segmentTransition?: Transition
29
+ segmentWrapperClassName?: string
30
+ speedReveal?: number
31
+ speedSegment?: number
32
+ style?: React.CSSProperties
33
+ trigger?: boolean
34
+ variants?: {
35
+ container?: Variants
36
+ item?: Variants
37
+ }
38
+ }
39
+
40
+ const defaultStaggerTimes: Record<PerType, number> = {
41
+ char: 0.03,
42
+ line: 0.1,
43
+ word: 0.05,
44
+ }
45
+
46
+ const defaultContainerVariants: Variants = {
47
+ hidden: { opacity: 0 },
48
+ exit: {
49
+ transition: { staggerChildren: 0.05, staggerDirection: -1 },
50
+ },
51
+ visible: {
52
+ opacity: 1,
53
+ transition: {
54
+ staggerChildren: 0.05,
55
+ },
56
+ },
57
+ }
58
+
59
+ const defaultItemVariants: Variants = {
60
+ hidden: { opacity: 0 },
61
+ exit: { opacity: 0 },
62
+ visible: {
63
+ opacity: 1,
64
+ },
65
+ }
66
+
67
+ const presetVariants: Record<
68
+ PresetType,
69
+ { container: Variants; item: Variants }
70
+ > = {
71
+ slide: {
72
+ container: defaultContainerVariants,
73
+ item: {
74
+ hidden: { opacity: 0, y: 20 },
75
+ exit: { opacity: 0, y: 20 },
76
+ visible: { opacity: 1, y: 0 },
77
+ },
78
+ },
79
+ blur: {
80
+ container: defaultContainerVariants,
81
+ item: {
82
+ hidden: { filter: "blur(12px)", opacity: 0 },
83
+ exit: { filter: "blur(12px)", opacity: 0 },
84
+ visible: { filter: "blur(0px)", opacity: 1 },
85
+ },
86
+ },
87
+ fade: {
88
+ container: defaultContainerVariants,
89
+ item: {
90
+ hidden: { opacity: 0 },
91
+ exit: { opacity: 0 },
92
+ visible: { opacity: 1 },
93
+ },
94
+ },
95
+ "fade-in-blur": {
96
+ container: defaultContainerVariants,
97
+ item: {
98
+ hidden: { filter: "blur(12px)", opacity: 0, y: 20 },
99
+ exit: { filter: "blur(12px)", opacity: 0, y: 20 },
100
+ visible: { filter: "blur(0px)", opacity: 1, y: 0 },
101
+ },
102
+ },
103
+ scale: {
104
+ container: defaultContainerVariants,
105
+ item: {
106
+ hidden: { opacity: 0, scale: 0 },
107
+ exit: { opacity: 0, scale: 0 },
108
+ visible: { opacity: 1, scale: 1 },
109
+ },
110
+ },
111
+ }
112
+
113
+ const AnimationComponent: React.FC<{
114
+ per: "char" | "line" | "word"
115
+ segment: string
116
+ segmentWrapperClassName?: string
117
+ variants: Variants
118
+ }> = React.memo(({ per, segment, segmentWrapperClassName, variants }) => {
119
+ const content =
120
+ per === "line" ? (
121
+ <motion.span className="block" variants={variants}>
122
+ {segment}
123
+ </motion.span>
124
+ ) : per === "word" ? (
125
+ <motion.span
126
+ aria-hidden="true"
127
+ className="inline-block whitespace-pre"
128
+ variants={variants}
129
+ >
130
+ {segment}
131
+ </motion.span>
132
+ ) : (
133
+ <motion.span className="inline-block whitespace-pre">
134
+ {segment.split("").map((char, charIndex) => (
135
+ <motion.span
136
+ aria-hidden="true"
137
+ className="inline-block whitespace-pre"
138
+ key={`char-${charIndex}`}
139
+ variants={variants}
140
+ >
141
+ {char}
142
+ </motion.span>
143
+ ))}
144
+ </motion.span>
145
+ )
146
+
147
+ if (!segmentWrapperClassName) {
148
+ return content
149
+ }
150
+
151
+ const defaultWrapperClassName = per === "line" ? "block" : "inline-block"
152
+
153
+ return (
154
+ <span className={cn(defaultWrapperClassName, segmentWrapperClassName)}>
155
+ {content}
156
+ </span>
157
+ )
158
+ })
159
+
160
+ AnimationComponent.displayName = "AnimationComponent"
161
+
162
+ const splitText = (text: string, per: "char" | "line" | "word") => {
163
+ if (per === "line") return text.split("\n")
164
+ return text.split(/(\s+)/)
165
+ }
166
+
167
+ const hasTransition = (
168
+ variant: Variant,
169
+ ): variant is TargetAndTransition & { transition?: Transition } => {
170
+ return (
171
+ typeof variant === "object" && variant !== null && "transition" in variant
172
+ )
173
+ }
174
+
175
+ const createVariantsWithTransition = (
176
+ baseVariants: Variants,
177
+ transition?: Transition & { exit?: Transition },
178
+ ): Variants => {
179
+ if (!transition) return baseVariants
180
+
181
+ const { ...mainTransition } = transition
182
+
183
+ return {
184
+ ...baseVariants,
185
+ exit: {
186
+ ...baseVariants.exit,
187
+ transition: {
188
+ ...(hasTransition(baseVariants.exit)
189
+ ? baseVariants.exit.transition
190
+ : {}),
191
+ ...mainTransition,
192
+ staggerDirection: -1,
193
+ },
194
+ },
195
+ visible: {
196
+ ...baseVariants.visible,
197
+ transition: {
198
+ ...(hasTransition(baseVariants.visible)
199
+ ? baseVariants.visible.transition
200
+ : {}),
201
+ ...mainTransition,
202
+ },
203
+ },
204
+ }
205
+ }
206
+
207
+ export function TextEffect({
208
+ as = "p",
209
+ children,
210
+ className,
211
+ containerTransition,
212
+ delay = 0,
213
+ onAnimationComplete,
214
+ onAnimationStart,
215
+ per = "word",
216
+ preset = "fade",
217
+ segmentTransition,
218
+ segmentWrapperClassName,
219
+ speedReveal = 1,
220
+ speedSegment = 1,
221
+ style,
222
+ trigger = true,
223
+ variants,
224
+ }: TextEffectProps) {
225
+ const segments = splitText(children, per)
226
+ const MotionTag = motion[as as keyof typeof motion] as typeof motion.div
227
+
228
+ const baseVariants = preset
229
+ ? presetVariants[preset]
230
+ : { container: defaultContainerVariants, item: defaultItemVariants }
231
+
232
+ const stagger = defaultStaggerTimes[per] / speedReveal
233
+
234
+ const baseDuration = 0.3 / speedSegment
235
+
236
+ const customStagger = hasTransition(variants?.container?.visible ?? {})
237
+ ? (variants?.container?.visible as TargetAndTransition).transition
238
+ ?.staggerChildren
239
+ : undefined
240
+
241
+ const customDelay = hasTransition(variants?.container?.visible ?? {})
242
+ ? (variants?.container?.visible as TargetAndTransition).transition
243
+ ?.delayChildren
244
+ : undefined
245
+
246
+ const computedVariants = {
247
+ container: createVariantsWithTransition(
248
+ variants?.container ?? baseVariants.container,
249
+ {
250
+ delayChildren: customDelay ?? delay,
251
+ staggerChildren: customStagger ?? stagger,
252
+ ...containerTransition,
253
+ exit: {
254
+ staggerChildren: customStagger ?? stagger,
255
+ staggerDirection: -1,
256
+ },
257
+ },
258
+ ),
259
+ item: createVariantsWithTransition(variants?.item ?? baseVariants.item, {
260
+ duration: baseDuration,
261
+ ...segmentTransition,
262
+ }),
263
+ }
264
+
265
+ return (
266
+ <AnimatePresence mode="popLayout">
267
+ {trigger && (
268
+ <MotionTag
269
+ animate="visible"
270
+ className={className}
271
+ exit="exit"
272
+ initial="hidden"
273
+ onAnimationComplete={onAnimationComplete}
274
+ onAnimationStart={onAnimationStart}
275
+ style={style}
276
+ variants={computedVariants.container}
277
+ >
278
+ {per !== "line" ? <span className="sr-only">{children}</span> : null}
279
+ {segments.map((segment, index) => (
280
+ <AnimationComponent
281
+ key={`${per}-${index}-${segment}`}
282
+ per={per}
283
+ segment={segment}
284
+ segmentWrapperClassName={segmentWrapperClassName}
285
+ variants={computedVariants.item}
286
+ />
287
+ ))}
288
+ </MotionTag>
289
+ )}
290
+ </AnimatePresence>
291
+ )
292
+ }
@@ -0,0 +1,74 @@
1
+ "use client"
2
+
3
+ import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
4
+
5
+ import { cn } from "~/lib/utils"
6
+ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
7
+
8
+ function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
9
+ return (
10
+ <AccordionPrimitive.Root
11
+ data-slot="accordion"
12
+ className={cn("flex w-full flex-col", className)}
13
+ {...props}
14
+ />
15
+ )
16
+ }
17
+
18
+ function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
19
+ return (
20
+ <AccordionPrimitive.Item
21
+ data-slot="accordion-item"
22
+ className={cn("not-last:border-b", className)}
23
+ {...props}
24
+ />
25
+ )
26
+ }
27
+
28
+ function AccordionTrigger({
29
+ className,
30
+ children,
31
+ ...props
32
+ }: AccordionPrimitive.Trigger.Props) {
33
+ return (
34
+ <AccordionPrimitive.Header className="flex">
35
+ <AccordionPrimitive.Trigger
36
+ data-slot="accordion-trigger"
37
+ className={cn(
38
+ "group/accordion-trigger relative flex flex-1 items-start justify-between rounded-md border border-transparent py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
39
+ className
40
+ )}
41
+ {...props}
42
+ >
43
+ {children}
44
+ <ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
45
+ <ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
46
+ </AccordionPrimitive.Trigger>
47
+ </AccordionPrimitive.Header>
48
+ )
49
+ }
50
+
51
+ function AccordionContent({
52
+ className,
53
+ children,
54
+ ...props
55
+ }: AccordionPrimitive.Panel.Props) {
56
+ return (
57
+ <AccordionPrimitive.Panel
58
+ data-slot="accordion-content"
59
+ className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
60
+ {...props}
61
+ >
62
+ <div
63
+ className={cn(
64
+ "h-(--accordion-panel-height) pt-0 pb-4 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
65
+ className
66
+ )}
67
+ >
68
+ {children}
69
+ </div>
70
+ </AccordionPrimitive.Panel>
71
+ )
72
+ }
73
+
74
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }