create-vexcms 0.0.7 → 0.0.9
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 +8 -8
- 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/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/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,70 @@
|
|
|
1
|
+
import { array, defineBlock, object, text } from "@vexcms/core"
|
|
2
|
+
|
|
3
|
+
import { BLOCK_SLUG_FAQ } from "../constants"
|
|
4
|
+
|
|
5
|
+
export const faqBlock = defineBlock({
|
|
6
|
+
slug: BLOCK_SLUG_FAQ,
|
|
7
|
+
label: "FAQ",
|
|
8
|
+
fields: {
|
|
9
|
+
heading: text({
|
|
10
|
+
label: "Heading",
|
|
11
|
+
required: true,
|
|
12
|
+
defaultValue: "Frequently Asked Questions",
|
|
13
|
+
}),
|
|
14
|
+
subheading: text({
|
|
15
|
+
label: "Subheading",
|
|
16
|
+
defaultValue:
|
|
17
|
+
"Everything you need to know about Vex CMS and building with Convex.",
|
|
18
|
+
}),
|
|
19
|
+
supportLink: text({
|
|
20
|
+
label: "Support Link",
|
|
21
|
+
defaultValue: "https://github.com/vexcms/vex/issues",
|
|
22
|
+
admin: { description: "URL to contact support page" },
|
|
23
|
+
}),
|
|
24
|
+
items: array({
|
|
25
|
+
label: "FAQ Items",
|
|
26
|
+
required: true,
|
|
27
|
+
items: object({
|
|
28
|
+
fields: {
|
|
29
|
+
question: text({ label: "Question", required: true }),
|
|
30
|
+
answer: text({ label: "Answer", required: true }),
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
defaultValue: [
|
|
34
|
+
{
|
|
35
|
+
question: "What is Vex CMS?",
|
|
36
|
+
answer:
|
|
37
|
+
"Vex CMS is a headless content management system built on Convex. It provides real-time data, type-safe schemas, draft/publish workflows, live preview, and a beautiful admin panel — all configured with TypeScript.",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
question: "How is Vex different from other headless CMS platforms?",
|
|
41
|
+
answer:
|
|
42
|
+
"Unlike traditional headless CMS platforms that use REST or GraphQL APIs, Vex is powered by Convex's real-time database. This means content updates are instant across all clients, with no polling or webhooks needed. Your schema is defined in code, giving you full type safety from database to frontend.",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
question: "Do I need to know Convex to use Vex CMS?",
|
|
46
|
+
answer:
|
|
47
|
+
"Basic familiarity with Convex helps, but Vex handles most of the complexity for you. The CLI generates your Convex schema, queries, and types automatically from your collection definitions. You just define your fields and Vex does the rest.",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
question: "Can I use Vex with any frontend framework?",
|
|
51
|
+
answer:
|
|
52
|
+
"Vex CMS is framework-agnostic at the data layer — any app that can use Convex can use Vex. The admin panel and UI components are built for Next.js, but the core package and generated queries work with any Convex-compatible frontend.",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
question: "Is Vex CMS free?",
|
|
56
|
+
answer:
|
|
57
|
+
"Yes, Vex CMS is open source and free to use. You only pay for your Convex usage, which has a generous free tier for most projects.",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
question: "How do I get started?",
|
|
61
|
+
answer:
|
|
62
|
+
"Run `npx create-vexcms@latest` to scaffold a new project with Vex CMS pre-configured. The CLI sets up your Next.js app, Convex backend, authentication, and admin panel in under a minute.",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
admin: {
|
|
68
|
+
icon: "help-circle",
|
|
69
|
+
},
|
|
70
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { BlockComponentProps } from "@vexcms/ui"
|
|
2
|
+
|
|
3
|
+
import Link from "next/link"
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Accordion,
|
|
7
|
+
AccordionContent,
|
|
8
|
+
AccordionItem,
|
|
9
|
+
AccordionTrigger,
|
|
10
|
+
} from "~/components/ui/accordion"
|
|
11
|
+
|
|
12
|
+
export { faqBlock } from "./config"
|
|
13
|
+
|
|
14
|
+
export default function FAQBlock({ block }: BlockComponentProps) {
|
|
15
|
+
const { heading, subheading, supportLink, items } = block as unknown as {
|
|
16
|
+
heading: string
|
|
17
|
+
subheading?: string
|
|
18
|
+
supportLink?: string
|
|
19
|
+
items?: Array<{ question: string; answer: string }>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<section className="py-16 md:py-24">
|
|
24
|
+
<div className="mx-auto max-w-5xl px-4 md:px-6">
|
|
25
|
+
<div className="mx-auto max-w-xl text-center">
|
|
26
|
+
<h2 className="text-3xl font-bold text-balance md:text-4xl lg:text-5xl">
|
|
27
|
+
{heading}
|
|
28
|
+
</h2>
|
|
29
|
+
{subheading && (
|
|
30
|
+
<p className="text-muted-foreground mt-4 text-balance">
|
|
31
|
+
{subheading}
|
|
32
|
+
</p>
|
|
33
|
+
)}
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div className="mx-auto mt-12 max-w-xl">
|
|
37
|
+
<Accordion className="bg-muted dark:bg-muted/50 w-full rounded-2xl p-1">
|
|
38
|
+
{(items ?? []).map((faq, index) => (
|
|
39
|
+
<div className="group" key={index}>
|
|
40
|
+
<AccordionItem
|
|
41
|
+
className="data-open:bg-card dark:data-open:bg-muted rounded-xl border-none px-7 py-1 data-open:border-none data-open:shadow-sm"
|
|
42
|
+
value={`${index}`}
|
|
43
|
+
>
|
|
44
|
+
<AccordionTrigger className="cursor-pointer text-base hover:no-underline">
|
|
45
|
+
{faq.question}
|
|
46
|
+
</AccordionTrigger>
|
|
47
|
+
<AccordionContent>
|
|
48
|
+
<p className="text-base">{faq.answer}</p>
|
|
49
|
+
</AccordionContent>
|
|
50
|
+
</AccordionItem>
|
|
51
|
+
<hr className="mx-7 border-dashed group-last:hidden" />
|
|
52
|
+
</div>
|
|
53
|
+
))}
|
|
54
|
+
</Accordion>
|
|
55
|
+
|
|
56
|
+
{supportLink && (
|
|
57
|
+
<p className="text-muted-foreground mt-6 px-8">
|
|
58
|
+
{"Can't find what you're looking for? Contact our "}
|
|
59
|
+
<Link
|
|
60
|
+
className="text-primary font-medium hover:underline"
|
|
61
|
+
href={supportLink}
|
|
62
|
+
>
|
|
63
|
+
support team
|
|
64
|
+
</Link>
|
|
65
|
+
</p>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</section>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { array, defineBlock, object, text } from "@vexcms/core"
|
|
2
|
+
|
|
3
|
+
import IconPickerField from "~/components/admin/IconPickerField"
|
|
4
|
+
import { BLOCK_SLUG_FEATURES } from "../constants"
|
|
5
|
+
|
|
6
|
+
export const featuresBlock = defineBlock({
|
|
7
|
+
slug: BLOCK_SLUG_FEATURES,
|
|
8
|
+
label: "Features",
|
|
9
|
+
fields: {
|
|
10
|
+
heading: text({
|
|
11
|
+
label: "Heading",
|
|
12
|
+
required: true,
|
|
13
|
+
defaultValue: "Everything you need to manage content",
|
|
14
|
+
}),
|
|
15
|
+
subheading: text({
|
|
16
|
+
label: "Subheading",
|
|
17
|
+
defaultValue:
|
|
18
|
+
"Built on Convex's real-time infrastructure with a developer experience that doesn't compromise on power.",
|
|
19
|
+
}),
|
|
20
|
+
features: array({
|
|
21
|
+
label: "Features",
|
|
22
|
+
required: true,
|
|
23
|
+
items: object({
|
|
24
|
+
fields: {
|
|
25
|
+
title: text({ label: "Title", required: true }),
|
|
26
|
+
description: text({ label: "Description", required: true }),
|
|
27
|
+
icon: text({
|
|
28
|
+
label: "Icon",
|
|
29
|
+
admin: {
|
|
30
|
+
description: "Lucide icon name",
|
|
31
|
+
components: { Field: IconPickerField },
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
defaultValue: [
|
|
37
|
+
{
|
|
38
|
+
title: "Real-Time by Default",
|
|
39
|
+
description:
|
|
40
|
+
"Every query is live. Content updates appear instantly across all connected clients — no polling, no webhooks.",
|
|
41
|
+
icon: "Zap",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
title: "Type-Safe Schemas",
|
|
45
|
+
description:
|
|
46
|
+
"Define your collections with TypeScript. Vex generates Convex schemas, Zod validators, and typed queries automatically.",
|
|
47
|
+
icon: "Shield",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
title: "Developer First",
|
|
51
|
+
description:
|
|
52
|
+
"Code-first configuration, CLI tooling, and a clean API. Build with the tools you already know and love.",
|
|
53
|
+
icon: "Code",
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
58
|
+
admin: {
|
|
59
|
+
icon: "layout-grid",
|
|
60
|
+
},
|
|
61
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { BlockComponentProps } from "@vexcms/ui"
|
|
2
|
+
import type { ComponentPropsWithRef } from "react"
|
|
3
|
+
|
|
4
|
+
import { icons } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { Card, CardContent, CardHeader } from "~/components/ui/card"
|
|
7
|
+
|
|
8
|
+
export { featuresBlock } from "./config"
|
|
9
|
+
|
|
10
|
+
function LucideIcon({ name }: { name: string }) {
|
|
11
|
+
const Icon = icons[name as keyof typeof icons]
|
|
12
|
+
if (!Icon) return null
|
|
13
|
+
return <Icon className="size-4" />
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function CardDecorator({ children, ...divProps }: ComponentPropsWithRef<"div">) {
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
className="relative mx-auto size-36 duration-200 [--color-border:color-mix(in_oklab,var(--color-zinc-950)10%,transparent)] group-hover:[--color-border:color-mix(in_oklab,var(--color-zinc-950)20%,transparent)] dark:[--color-border:color-mix(in_oklab,var(--color-white)15%,transparent)] dark:group-hover:bg-white/5 dark:group-hover:[--color-border:color-mix(in_oklab,var(--color-white)20%,transparent)]"
|
|
20
|
+
{...divProps}
|
|
21
|
+
>
|
|
22
|
+
<div
|
|
23
|
+
aria-hidden
|
|
24
|
+
className="absolute inset-0 bg-[linear-gradient(to_right,var(--color-border)_1px,transparent_1px),linear-gradient(to_bottom,var(--color-border)_1px,transparent_1px)] bg-[size:24px_24px]"
|
|
25
|
+
/>
|
|
26
|
+
<div
|
|
27
|
+
aria-hidden
|
|
28
|
+
className="to-background absolute inset-0 bg-radial from-transparent to-75%"
|
|
29
|
+
/>
|
|
30
|
+
<div className="bg-background absolute inset-0 m-auto flex size-12 items-center justify-center border-t border-l">
|
|
31
|
+
{children}
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default function FeaturesBlock({ block }: BlockComponentProps) {
|
|
38
|
+
const { heading, subheading, features } = block as unknown as {
|
|
39
|
+
heading: string
|
|
40
|
+
subheading?: string
|
|
41
|
+
features?: Array<{ title: string; description: string; icon?: string }>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<section className="bg-zinc-50 py-16 md:py-32 dark:bg-transparent">
|
|
46
|
+
<div className="@container mx-auto max-w-5xl px-6">
|
|
47
|
+
<div className="text-center">
|
|
48
|
+
<h2 className="text-4xl font-semibold text-balance lg:text-5xl">
|
|
49
|
+
{heading}
|
|
50
|
+
</h2>
|
|
51
|
+
{subheading && <p className="mt-4">{subheading}</p>}
|
|
52
|
+
</div>
|
|
53
|
+
<Card className="mx-auto mt-8 grid max-w-sm divide-y overflow-hidden shadow-zinc-950/5 *:text-center md:mt-16 @min-4xl:max-w-full @min-4xl:grid-cols-3 @min-4xl:divide-x @min-4xl:divide-y-0">
|
|
54
|
+
{(features ?? []).map((feature, index) => (
|
|
55
|
+
<div className="group shadow-zinc-950/5" key={index}>
|
|
56
|
+
<CardHeader className="pb-3">
|
|
57
|
+
{feature.icon && (
|
|
58
|
+
<CardDecorator>
|
|
59
|
+
<LucideIcon name={feature.icon} />
|
|
60
|
+
</CardDecorator>
|
|
61
|
+
)}
|
|
62
|
+
<h3 className="mt-6 font-medium">{feature.title}</h3>
|
|
63
|
+
</CardHeader>
|
|
64
|
+
<CardContent>
|
|
65
|
+
<p className="text-sm">{feature.description}</p>
|
|
66
|
+
</CardContent>
|
|
67
|
+
</div>
|
|
68
|
+
))}
|
|
69
|
+
</Card>
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { array, defineBlock, object, text, upload } from "@vexcms/core"
|
|
2
|
+
|
|
3
|
+
import IconPickerField from "~/components/admin/IconPickerField"
|
|
4
|
+
import { TABLE_SLUG_MEDIA } from "~/db/constants"
|
|
5
|
+
import { BLOCK_SLUG_FOOTER } from "../constants"
|
|
6
|
+
|
|
7
|
+
export const footerBlock = defineBlock({
|
|
8
|
+
slug: BLOCK_SLUG_FOOTER,
|
|
9
|
+
label: "Footer",
|
|
10
|
+
fields: {
|
|
11
|
+
logoText: text({
|
|
12
|
+
label: "Logo Text",
|
|
13
|
+
defaultValue: "Vex CMS",
|
|
14
|
+
}),
|
|
15
|
+
logoImage: upload({
|
|
16
|
+
label: "Logo Image",
|
|
17
|
+
to: TABLE_SLUG_MEDIA,
|
|
18
|
+
}),
|
|
19
|
+
copyright: text({
|
|
20
|
+
label: "Copyright Text",
|
|
21
|
+
defaultValue: "Vex CMS. All rights reserved.",
|
|
22
|
+
}),
|
|
23
|
+
links: array({
|
|
24
|
+
label: "Links",
|
|
25
|
+
items: object({
|
|
26
|
+
fields: {
|
|
27
|
+
label: text({ label: "Label", required: true }),
|
|
28
|
+
href: text({ label: "Link", required: true }),
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
defaultValue: [
|
|
32
|
+
{ label: "Features", href: "/features" },
|
|
33
|
+
{ label: "Pricing", href: "/pricing" },
|
|
34
|
+
{ label: "Roadmap", href: "/roadmap" },
|
|
35
|
+
{ label: "Documentation", href: "/docs" },
|
|
36
|
+
{ label: "GitHub", href: "https://github.com/vexcms/vex" },
|
|
37
|
+
{ label: "npm", href: "https://www.npmjs.com/package/@vexcms/core" },
|
|
38
|
+
{ label: "Convex", href: "https://convex.dev" },
|
|
39
|
+
],
|
|
40
|
+
}),
|
|
41
|
+
socialLinks: array({
|
|
42
|
+
label: "Social Links",
|
|
43
|
+
items: object({
|
|
44
|
+
fields: {
|
|
45
|
+
platform: text({ label: "Platform", required: true }),
|
|
46
|
+
href: text({ label: "URL", required: true }),
|
|
47
|
+
icon: text({
|
|
48
|
+
label: "Icon",
|
|
49
|
+
admin: {
|
|
50
|
+
description: "Lucide icon name",
|
|
51
|
+
components: { Field: IconPickerField },
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
defaultValue: [
|
|
57
|
+
{ platform: "GitHub", href: "https://github.com/vexcms/vex", icon: "Github" },
|
|
58
|
+
{ platform: "X", href: "https://x.com/vexcms", icon: "Twitter" },
|
|
59
|
+
],
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
admin: {
|
|
63
|
+
icon: "panel-bottom",
|
|
64
|
+
},
|
|
65
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { BlockComponentProps } from "@vexcms/ui"
|
|
2
|
+
|
|
3
|
+
import { icons } from "lucide-react"
|
|
4
|
+
import Link from "next/link"
|
|
5
|
+
|
|
6
|
+
export { footerBlock } from "./config"
|
|
7
|
+
|
|
8
|
+
function SocialIcon({ name }: { name: string }) {
|
|
9
|
+
const Icon = icons[name as keyof typeof icons]
|
|
10
|
+
if (!Icon) return null
|
|
11
|
+
return <Icon className="size-5" />
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function FooterBlock({ block }: BlockComponentProps) {
|
|
15
|
+
const { logoText, copyright, links, socialLinks } = block as {
|
|
16
|
+
logoText?: string
|
|
17
|
+
logoImage?: string
|
|
18
|
+
copyright?: string
|
|
19
|
+
links?: Array<{ label: string; href: string }>
|
|
20
|
+
socialLinks?: Array<{ platform: string; href: string; icon?: string }>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<footer>
|
|
25
|
+
<div className="mx-auto max-w-5xl px-6 py-8 md:py-16">
|
|
26
|
+
<Link
|
|
27
|
+
aria-label="go home"
|
|
28
|
+
className="mx-auto block w-fit text-lg font-semibold"
|
|
29
|
+
href="/"
|
|
30
|
+
>
|
|
31
|
+
{logoText ?? "Vex CMS"}
|
|
32
|
+
</Link>
|
|
33
|
+
|
|
34
|
+
<div className="mt-6 flex flex-wrap justify-center gap-6 text-sm">
|
|
35
|
+
{(links ?? []).map((link, index) => (
|
|
36
|
+
<Link
|
|
37
|
+
className="text-muted-foreground hover:text-primary block duration-150"
|
|
38
|
+
href={link.href}
|
|
39
|
+
key={index}
|
|
40
|
+
>
|
|
41
|
+
<span>{link.label}</span>
|
|
42
|
+
</Link>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
{socialLinks && socialLinks.length > 0 && (
|
|
47
|
+
<div className="my-8 flex flex-wrap justify-center gap-6 text-sm">
|
|
48
|
+
{socialLinks.map((social, index) => (
|
|
49
|
+
<Link
|
|
50
|
+
key={index}
|
|
51
|
+
href={social.href}
|
|
52
|
+
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
53
|
+
aria-label={social.platform}
|
|
54
|
+
>
|
|
55
|
+
{social.icon ? (
|
|
56
|
+
<SocialIcon name={social.icon} />
|
|
57
|
+
) : (
|
|
58
|
+
<span className="text-sm">{social.platform}</span>
|
|
59
|
+
)}
|
|
60
|
+
</Link>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
|
|
65
|
+
<span className="text-muted-foreground block text-center text-sm">
|
|
66
|
+
© {new Date().getFullYear()} {copyright ?? "All rights reserved"}
|
|
67
|
+
</span>
|
|
68
|
+
</div>
|
|
69
|
+
</footer>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { array, defineBlock, object, select, text, upload } from "@vexcms/core"
|
|
2
|
+
|
|
3
|
+
import { TABLE_SLUG_MEDIA } from "~/db/constants"
|
|
4
|
+
import { BLOCK_SLUG_HEADER } from "../constants"
|
|
5
|
+
|
|
6
|
+
export const headerBlock = defineBlock({
|
|
7
|
+
slug: BLOCK_SLUG_HEADER,
|
|
8
|
+
label: "Header",
|
|
9
|
+
fields: {
|
|
10
|
+
logoText: text({
|
|
11
|
+
label: "Logo Text",
|
|
12
|
+
defaultValue: "Vex CMS",
|
|
13
|
+
admin: { description: "Text to display as the logo" },
|
|
14
|
+
}),
|
|
15
|
+
logoImage: upload({
|
|
16
|
+
label: "Logo Image",
|
|
17
|
+
to: TABLE_SLUG_MEDIA,
|
|
18
|
+
admin: { description: "Image to display as the logo (overrides text)" },
|
|
19
|
+
}),
|
|
20
|
+
logoHref: text({
|
|
21
|
+
label: "Logo Link",
|
|
22
|
+
defaultValue: "/",
|
|
23
|
+
}),
|
|
24
|
+
menuItems: array({
|
|
25
|
+
label: "Menu Items",
|
|
26
|
+
items: object({
|
|
27
|
+
fields: {
|
|
28
|
+
label: text({ label: "Label", required: true }),
|
|
29
|
+
href: text({ label: "Link", required: true }),
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
defaultValue: [
|
|
33
|
+
{ label: "Features", href: "/features" },
|
|
34
|
+
{ label: "Pricing", href: "/pricing" },
|
|
35
|
+
{ label: "Roadmap", href: "/roadmap" },
|
|
36
|
+
{ label: "Docs", href: "/docs" },
|
|
37
|
+
],
|
|
38
|
+
}),
|
|
39
|
+
actionButtons: array({
|
|
40
|
+
label: "Action Buttons",
|
|
41
|
+
items: object({
|
|
42
|
+
fields: {
|
|
43
|
+
label: text({ label: "Button Label", required: true }),
|
|
44
|
+
href: text({ label: "Button Link", required: true }),
|
|
45
|
+
variant: select({
|
|
46
|
+
label: "Variant",
|
|
47
|
+
options: [
|
|
48
|
+
{ label: "Default", value: "default" },
|
|
49
|
+
{ label: "Outline", value: "outline" },
|
|
50
|
+
{ label: "Ghost", value: "ghost" },
|
|
51
|
+
],
|
|
52
|
+
defaultValue: "default",
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
defaultValue: [
|
|
57
|
+
{ label: "GitHub", href: "https://github.com/vexcms/vex", variant: "ghost" },
|
|
58
|
+
{ label: "Get Started", href: "/docs", variant: "default" },
|
|
59
|
+
],
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
admin: {
|
|
63
|
+
icon: "panel-top",
|
|
64
|
+
},
|
|
65
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { BlockComponentProps } from "@vexcms/ui"
|
|
4
|
+
|
|
5
|
+
import { Menu, Settings, X } from "lucide-react"
|
|
6
|
+
import Link from "next/link"
|
|
7
|
+
import { useEffect, useState } from "react"
|
|
8
|
+
|
|
9
|
+
import { useSession } from "~/auth/client"
|
|
10
|
+
import { cn } from "~/lib/utils"
|
|
11
|
+
import { buttonVariants } from "~/components/ui/button"
|
|
12
|
+
|
|
13
|
+
export { headerBlock } from "./config"
|
|
14
|
+
|
|
15
|
+
export default function HeaderBlock({ block }: BlockComponentProps) {
|
|
16
|
+
const {
|
|
17
|
+
logoText,
|
|
18
|
+
logoHref = "/",
|
|
19
|
+
menuItems,
|
|
20
|
+
actionButtons,
|
|
21
|
+
} = block as {
|
|
22
|
+
logoText?: string
|
|
23
|
+
logoImage?: string
|
|
24
|
+
logoHref?: string
|
|
25
|
+
menuItems?: Array<{ label: string; href: string }>
|
|
26
|
+
actionButtons?: Array<{ label: string; href: string; variant?: string }>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { data: session } = useSession()
|
|
30
|
+
const isAdmin = session?.user?.role === "admin" ||
|
|
31
|
+
(Array.isArray((session?.user as any)?.role) && (session?.user as any)?.role?.includes("admin"))
|
|
32
|
+
|
|
33
|
+
const [menuState, setMenuState] = useState(false)
|
|
34
|
+
const [isScrolled, setIsScrolled] = useState(false)
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const handleScroll = () => {
|
|
38
|
+
setIsScrolled(window.scrollY > 50)
|
|
39
|
+
}
|
|
40
|
+
window.addEventListener("scroll", handleScroll)
|
|
41
|
+
return () => window.removeEventListener("scroll", handleScroll)
|
|
42
|
+
}, [])
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<header>
|
|
46
|
+
<nav
|
|
47
|
+
className="fixed z-20 w-full px-2"
|
|
48
|
+
data-state={menuState ? "active" : undefined}
|
|
49
|
+
>
|
|
50
|
+
<div
|
|
51
|
+
className={cn(
|
|
52
|
+
"mx-auto mt-2 max-w-6xl px-6 transition-all duration-300 lg:px-12",
|
|
53
|
+
isScrolled &&
|
|
54
|
+
"bg-background/50 max-w-4xl rounded-2xl border backdrop-blur-lg lg:px-5"
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
<div className="relative flex flex-wrap items-center justify-between gap-6 py-3 lg:gap-0 lg:py-4">
|
|
58
|
+
<div className="flex w-full justify-between lg:w-auto">
|
|
59
|
+
<Link
|
|
60
|
+
aria-label="home"
|
|
61
|
+
className="flex items-center gap-2 text-lg font-semibold"
|
|
62
|
+
href={logoHref}
|
|
63
|
+
>
|
|
64
|
+
{logoText ?? "Vex CMS"}
|
|
65
|
+
</Link>
|
|
66
|
+
|
|
67
|
+
<button
|
|
68
|
+
aria-label={menuState ? "Close Menu" : "Open Menu"}
|
|
69
|
+
className="relative z-20 -m-2.5 -mr-4 block cursor-pointer p-2.5 lg:hidden"
|
|
70
|
+
onClick={() => setMenuState(!menuState)}
|
|
71
|
+
>
|
|
72
|
+
<Menu className="m-auto size-6 duration-200 in-data-[state=active]:scale-0 in-data-[state=active]:rotate-180 in-data-[state=active]:opacity-0" />
|
|
73
|
+
<X className="absolute inset-0 m-auto size-6 scale-0 -rotate-180 opacity-0 duration-200 in-data-[state=active]:scale-100 in-data-[state=active]:rotate-0 in-data-[state=active]:opacity-100" />
|
|
74
|
+
</button>
|
|
75
|
+
|
|
76
|
+
<div className="absolute inset-0 m-auto hidden size-fit lg:block">
|
|
77
|
+
<ul className="flex gap-8 text-sm">
|
|
78
|
+
{(menuItems ?? []).map((item, index) => (
|
|
79
|
+
<li key={index}>
|
|
80
|
+
<Link
|
|
81
|
+
className="text-muted-foreground hover:text-accent-foreground block duration-150"
|
|
82
|
+
href={item.href}
|
|
83
|
+
>
|
|
84
|
+
<span>{item.label}</span>
|
|
85
|
+
</Link>
|
|
86
|
+
</li>
|
|
87
|
+
))}
|
|
88
|
+
</ul>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div className="hidden lg:flex lg:gap-3">
|
|
93
|
+
{(actionButtons ?? []).map((button, index) => (
|
|
94
|
+
<Link
|
|
95
|
+
key={index}
|
|
96
|
+
href={button.href}
|
|
97
|
+
className={cn(
|
|
98
|
+
buttonVariants({
|
|
99
|
+
size: "sm",
|
|
100
|
+
variant: (button.variant as "default" | "outline" | "ghost") ?? "default",
|
|
101
|
+
})
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
<span>{button.label}</span>
|
|
105
|
+
</Link>
|
|
106
|
+
))}
|
|
107
|
+
{isAdmin && (
|
|
108
|
+
<Link
|
|
109
|
+
href="/admin"
|
|
110
|
+
className={cn(
|
|
111
|
+
buttonVariants({ size: "sm", variant: "outline" }),
|
|
112
|
+
"gap-1.5"
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
<Settings className="size-3.5" />
|
|
116
|
+
<span>Admin</span>
|
|
117
|
+
</Link>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* Mobile menu */}
|
|
122
|
+
<div className="bg-background absolute top-[125%] z-10 mb-6 hidden w-full flex-wrap items-center justify-end space-y-8 rounded-3xl border p-6 shadow-2xl shadow-zinc-300/20 in-data-[state=active]:block md:flex-nowrap lg:m-0 lg:w-fit lg:space-y-0 lg:border-transparent lg:bg-transparent lg:p-0 lg:shadow-none dark:shadow-none dark:lg:bg-transparent">
|
|
123
|
+
<div className="lg:hidden">
|
|
124
|
+
<ul className="space-y-6 text-base">
|
|
125
|
+
{(menuItems ?? []).map((item, index) => (
|
|
126
|
+
<li key={index}>
|
|
127
|
+
<Link
|
|
128
|
+
className="text-muted-foreground hover:text-accent-foreground block duration-150"
|
|
129
|
+
href={item.href}
|
|
130
|
+
>
|
|
131
|
+
<span>{item.label}</span>
|
|
132
|
+
</Link>
|
|
133
|
+
</li>
|
|
134
|
+
))}
|
|
135
|
+
</ul>
|
|
136
|
+
|
|
137
|
+
{(actionButtons && actionButtons.length > 0 || isAdmin) && (
|
|
138
|
+
<div className="mt-6 flex flex-col space-y-3">
|
|
139
|
+
{(actionButtons ?? []).map((button, index) => (
|
|
140
|
+
<Link
|
|
141
|
+
key={index}
|
|
142
|
+
href={button.href}
|
|
143
|
+
className={cn(
|
|
144
|
+
buttonVariants({
|
|
145
|
+
size: "sm",
|
|
146
|
+
variant: (button.variant as "default" | "outline" | "ghost") ?? "default",
|
|
147
|
+
})
|
|
148
|
+
)}
|
|
149
|
+
>
|
|
150
|
+
<span>{button.label}</span>
|
|
151
|
+
</Link>
|
|
152
|
+
))}
|
|
153
|
+
{isAdmin && (
|
|
154
|
+
<Link
|
|
155
|
+
href="/admin"
|
|
156
|
+
className={cn(
|
|
157
|
+
buttonVariants({ size: "sm", variant: "outline" }),
|
|
158
|
+
"gap-1.5"
|
|
159
|
+
)}
|
|
160
|
+
>
|
|
161
|
+
<Settings className="size-3.5" />
|
|
162
|
+
<span>Admin</span>
|
|
163
|
+
</Link>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</nav>
|
|
172
|
+
</header>
|
|
173
|
+
)
|
|
174
|
+
}
|