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.
- package/package.json +1 -1
- package/templates/base-nextjs/convex/vex/globals.ts +82 -0
- package/templates/base-nextjs/convex/vex/versions.ts +66 -6
- package/templates/base-nextjs/package.json +7 -7
- package/templates/base-nextjs/src/app/admin/layout.tsx +29 -11
- package/templates/base-nextjs/src/db/constants/auth.ts +1 -3
- package/templates/base-nextjs/src/vexcms/access.ts +29 -13
- package/templates/base-nextjs/src/vexcms/collections/users.ts +2 -2
- package/templates/base-nextjs/vex.config.ts +2 -0
- package/templates/marketing-site/convex/footers.ts +14 -0
- package/templates/marketing-site/convex/headers.ts +14 -0
- package/templates/marketing-site/convex/siteSettings.ts +14 -0
- package/templates/marketing-site/convex/theme.ts +75 -0
- package/templates/marketing-site/public/favicons/favicon.ico +0 -0
- package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +74 -0
- package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +19 -51
- package/templates/marketing-site/src/app/(frontend)/layout.tsx +37 -0
- package/templates/marketing-site/src/app/(frontend)/page.tsx +24 -0
- package/templates/marketing-site/src/app/(frontend)/preview/[slug]/PreviewPageContent.tsx +55 -0
- package/templates/marketing-site/src/app/(frontend)/preview/[slug]/page.tsx +19 -63
- package/templates/marketing-site/src/app/(frontend)/preview/layout.tsx +18 -0
- package/templates/marketing-site/src/components/SiteFooter.tsx +28 -0
- package/templates/marketing-site/src/components/SiteHeader.tsx +28 -0
- package/templates/marketing-site/src/components/ThemeInjector.tsx +128 -0
- package/templates/marketing-site/src/components/ThemeStyle.tsx +104 -0
- package/templates/marketing-site/src/components/admin/IconPickerField.tsx +134 -0
- package/templates/marketing-site/src/components/admin/ThemeImport.tsx +174 -0
- package/templates/marketing-site/src/components/admin/ThemeImportField.tsx +33 -0
- package/templates/marketing-site/src/components/motion-primitives/animated-group.tsx +138 -0
- package/templates/marketing-site/src/components/motion-primitives/text-effect.tsx +292 -0
- package/templates/marketing-site/src/components/ui/accordion.tsx +74 -0
- package/templates/marketing-site/src/lib/colorConvert.ts +171 -0
- package/templates/marketing-site/src/lib/metadata.ts +104 -0
- package/templates/marketing-site/src/vexcms/access.ts +28 -0
- package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +36 -0
- package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +46 -0
- package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +70 -0
- package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +71 -0
- package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +61 -0
- package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +73 -0
- package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +65 -0
- package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +71 -0
- package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +65 -0
- package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +174 -0
- package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +52 -0
- package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +152 -0
- package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +68 -0
- package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +74 -0
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +154 -0
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +122 -0
- package/templates/marketing-site/src/vexcms/blocks/config.ts +40 -0
- package/templates/marketing-site/src/vexcms/blocks/constants.ts +9 -0
- package/templates/marketing-site/src/vexcms/blocks/index.ts +39 -0
- package/templates/marketing-site/src/vexcms/collections/footers.ts +11 -8
- package/templates/marketing-site/src/vexcms/collections/headers.ts +10 -14
- package/templates/marketing-site/src/vexcms/collections/index.ts +0 -1
- package/templates/marketing-site/src/vexcms/collections/pages.ts +30 -4
- package/templates/marketing-site/src/vexcms/collections/themes.ts +107 -14
- package/templates/marketing-site/src/vexcms/globals/index.ts +1 -0
- package/templates/marketing-site/src/vexcms/globals/siteSettings.ts +57 -0
- package/templates/marketing-site/vex.config.ts +5 -5
- package/templates/base-nextjs/src/auth/permissions.ts +0 -102
- 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 }
|