commit 3e072967e6d9089f9dfc25145bc1b86fed52059b Author: Rami Bitar Date: Sat Aug 29 14:03:50 2026 -0400 Initial react editor website template diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f7305b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.next/ +node_modules/ +.env* +!.env.example +*.log +*.tsbuildinfo +.DS_Store +.yarn/install-state.gz diff --git a/README.md b/README.md new file mode 100644 index 0000000..871ffe5 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# React Editor Website + +A clean, commerce-neutral website template built with Next.js, React Editor, shadcn-style components, Tailwind CSS v4, and `next-themes`. + +## Included + +- Responsive Header and Footer stored as synced global blocks +- Hero, Logo Marquee, Features, Stats, Testimonials, Pricing, Posts Carousel, FAQ, and CTA blocks +- Contact page with an accessible form and validated API endpoint +- Light, dark, and system themes +- JSON-backed page content with visual editor routes +- Per-page SEO fields + +## Run locally + +```bash +yarn install +yarn dev +``` + +Open `/editor` for the home page editor and `/contact/editor` for the contact page editor. Publishing from the editor updates the matching `page.json` and shared `app.globals.json` files when the filesystem is writable. + +## Block structure + +Each editable block pairs a rendering component with a React Editor configuration: + +```text +components/website/pricing.tsx +components/website/pricing.config.tsx +``` + +Register new blocks in `editor.config.tsx` and add their prop type to `config/types.ts`. + +## Contact submissions + +`app/api/contact/route.ts` validates incoming data but intentionally does not choose a database, CRM, or email provider. Replace the marked storage comment with the integration required by the project using this template. + +## Customize + +- Global content: `app.globals.json` +- Home page: `app/page.json` +- Contact page: `app/contact/page.json` +- Site identity and canonical URL: `config/site.ts` +- Theme tokens: `app/globals.css` diff --git a/app.globals.json b/app.globals.json new file mode 100644 index 0000000..3d57354 --- /dev/null +++ b/app.globals.json @@ -0,0 +1,37 @@ +{ + "header": { + "props": { + "siteName": "Your Company", + "logoUrl": "", + "links": [ + { "label": "Features", "url": "/#features" }, + { "label": "Pricing", "url": "/#pricing" }, + { "label": "Resources", "url": "/#resources" }, + { "label": "FAQ", "url": "/#faq" } + ], + "ctaLabel": "Contact", + "ctaUrl": "/contact", + "showThemeToggle": "yes", + "sticky": "yes" + } + }, + "footer": { + "props": { + "siteName": "Your Company", + "logoUrl": "", + "description": "Add a short description of your business.", + "links": [ + { "label": "Features", "url": "/#features" }, + { "label": "Pricing", "url": "/#pricing" }, + { "label": "Resources", "url": "/#resources" }, + { "label": "Contact", "url": "/contact" } + ], + "socialLinks": [ + { "label": "LinkedIn", "url": "#" }, + { "label": "Instagram", "url": "#" } + ], + "copyright": "© 2026 Your Company. All rights reserved.", + "showThemeToggle": "yes" + } + } +} diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts new file mode 100644 index 0000000..eb47cf4 --- /dev/null +++ b/app/api/contact/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +const contactSchema = z.object({ + name: z.string().trim().min(2, 'Please enter your name.').max(100), + email: z.string().trim().email('Please enter a valid email address.').max(254), + company: z.string().trim().max(120).optional().default(''), + message: z.string().trim().min(10, 'Please share a little more detail.').max(5000), + website: z.string().max(0).optional().default(''), +}); + +export async function POST(request: Request) { + try { + const payload: unknown = await request.json(); + const result = contactSchema.safeParse(payload); + + if (!result.success) { + return NextResponse.json( + { error: result.error.issues[0]?.message ?? 'Please check the form and try again.' }, + { status: 400 } + ); + } + + // Store the validated contact data here. + // For example: await db.contactSubmission.create({ data: result.data }); + + return NextResponse.json({ ok: true }, { status: 202 }); + } catch { + return NextResponse.json({ error: 'Invalid request.' }, { status: 400 }); + } +} diff --git a/app/contact/editor/page.tsx b/app/contact/editor/page.tsx new file mode 100644 index 0000000..8cc7941 --- /dev/null +++ b/app/contact/editor/page.tsx @@ -0,0 +1,6 @@ +import PageEditor from '@/components/page-editor'; +import page from '../page.json'; + +export default function ContactEditorPage() { + return ; +} diff --git a/app/contact/page.json b/app/contact/page.json new file mode 100644 index 0000000..6bcd167 --- /dev/null +++ b/app/contact/page.json @@ -0,0 +1,29 @@ +{ + "root": { + "props": { + "title": "Contact — Your Company", + "description": "Tell us what you are working on and how we can help.", + "ogImage": "" + } + }, + "content": [ + { "type": "header", "props": { "id": "header" }, "synced": true }, + { + "type": "contact-form", + "props": { + "id": "contact-form", + "heading": "Let’s make something meaningful.", + "description": "Tell us what you are working on, where you are stuck, or what you want to make possible.", + "email": "hello@example.com", + "location": "New York, NY", + "nameLabel": "Name", + "emailLabel": "Email", + "companyLabel": "Company", + "messageLabel": "How can we help?", + "submitLabel": "Send message", + "successMessage": "Thanks — we will be in touch soon." + } + }, + { "type": "footer", "props": { "id": "footer" }, "synced": true } + ] +} diff --git a/app/contact/page.tsx b/app/contact/page.tsx new file mode 100644 index 0000000..b795717 --- /dev/null +++ b/app/contact/page.tsx @@ -0,0 +1,9 @@ +import PageRender from '@/components/page-render'; +import { pageMetadata } from '@/lib/page-metadata'; +import page from './page.json'; + +export const metadata = pageMetadata(page, { path: '/contact' }); + +export default function ContactPage() { + return ; +} diff --git a/app/editor/page.tsx b/app/editor/page.tsx new file mode 100644 index 0000000..e5d9d07 --- /dev/null +++ b/app/editor/page.tsx @@ -0,0 +1,6 @@ +import PageEditor from '@/components/page-editor'; +import page from '../page.json'; + +export default function EditorPage() { + return ; +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..d2e3e79 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,137 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.22 0.02 245); + --card: oklch(1 0 0); + --card-foreground: oklch(0.22 0.02 245); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.22 0.02 245); + --primary: oklch(0.23 0.025 245); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.96 0.008 245); + --secondary-foreground: oklch(0.23 0.025 245); + --muted: oklch(0.97 0.007 245); + --muted-foreground: oklch(0.5 0.015 245); + --accent: oklch(0.94 0.025 250); + --accent-foreground: oklch(0.34 0.12 255); + --destructive: oklch(0.58 0.22 27); + --border: oklch(0.88 0.01 245); + --input: oklch(0.88 0.01 245); + --ring: oklch(0.56 0.2 255); + --brand: oklch(0.56 0.21 255); + --brand-soft: oklch(0.94 0.03 250); + --radius: 0.625rem; +} + +.dark { + --background: oklch(0.15 0.014 264); + --foreground: oklch(0.95 0.006 85); + --card: oklch(0.19 0.016 264); + --card-foreground: oklch(0.95 0.006 85); + --popover: oklch(0.19 0.016 264); + --popover-foreground: oklch(0.95 0.006 85); + --primary: oklch(0.92 0.012 85); + --primary-foreground: oklch(0.18 0.012 264); + --secondary: oklch(0.24 0.018 264); + --secondary-foreground: oklch(0.93 0.008 85); + --muted: oklch(0.22 0.016 264); + --muted-foreground: oklch(0.69 0.012 85); + --accent: oklch(0.27 0.035 155); + --accent-foreground: oklch(0.9 0.04 155); + --destructive: oklch(0.7 0.18 27); + --border: oklch(1 0 0 / 12%); + --input: oklch(1 0 0 / 16%); + --ring: oklch(0.7 0.1 155); + --brand: oklch(0.7 0.16 250); + --brand-soft: oklch(0.27 0.04 250); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-brand: var(--brand); + --color-brand-soft: var(--brand-soft); + --radius-sm: calc(var(--radius) - 6px); + --radius-md: calc(var(--radius) - 3px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 6px); + --font-sans: var(--font-outfit), ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +@layer base { + * { + border-color: var(--border); + } + + html { + scroll-behavior: smooth; + } + + body { + background: var(--background); + color: var(--foreground); + font-feature-settings: 'kern' 1, 'tnum' 1; + } + + h1, + h2, + h3 { + text-wrap: balance; + } + + p { + text-wrap: pretty; + } +} + +@keyframes marquee { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} + +.animate-marquee { + animation: marquee var(--marquee-duration, 28s) linear infinite; +} + +.marquee-reverse { + animation-direction: reverse; +} + +.marquee-pause:hover { + animation-play-state: paused; +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + .animate-marquee { animation: none; transform: none; } +} + +.section-shell { + width: min(100% - 2rem, 80rem); + margin-inline: auto; +} + +@media (min-width: 768px) { + .section-shell { width: min(100% - 5rem, 80rem); } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..7dcd960 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from 'next'; +import { Outfit } from 'next/font/google'; +import { ThemeProvider } from '@/components/theme-provider'; +import { site } from '@/config/site'; +import './globals.css'; + +const outfit = Outfit({ + subsets: ['latin'], + variable: '--font-outfit', +}); + +export const metadata: Metadata = { + metadataBase: new URL(site.url), + title: { default: site.name, template: `%s — ${site.name}` }, + description: site.description, + openGraph: { + type: 'website', + siteName: site.name, + title: site.name, + description: site.description, + }, + twitter: { + card: 'summary_large_image', + title: site.name, + description: site.description, + }, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); +} diff --git a/app/page.json b/app/page.json new file mode 100644 index 0000000..dcd52f3 --- /dev/null +++ b/app/page.json @@ -0,0 +1,142 @@ +{ + "root": { + "props": { + "title": "Your Company — A clear website headline", + "description": "A short description of your business and the value it provides.", + "ogImage": "" + } + }, + "content": [ + { "type": "header", "props": { "id": "header" }, "synced": true }, + { + "type": "hero", + "props": { + "id": "hero", + "heading": "A clear headline about what your business does.", + "description": "Use this space to explain your value in one or two simple sentences.", + "primaryLabel": "Get started", + "primaryUrl": "/contact", + "secondaryLabel": "", + "secondaryUrl": "#features", + "imageUrl": "https://api.dicebear.com/10.x/glass/svg", + "imageAlt": "Abstract glass avatar illustration", + "imagePosition": "right" + } + }, + { + "type": "logo-marquee", + "props": { + "id": "logos", + "heading": "Trusted by teams at", + "logos": [ + { "name": "Logo One", "imageUrl": "" }, + { "name": "Logo Two", "imageUrl": "" }, + { "name": "Logo Three", "imageUrl": "" }, + { "name": "Logo Four", "imageUrl": "" }, + { "name": "Logo Five", "imageUrl": "" } + ], + "speed": "normal", + "direction": "left", + "pauseOnHover": "yes" + } + }, + { + "type": "features", + "props": { + "id": "features", + "heading": "Explain the most important benefits of your business.", + "description": "", + "imageUrl": "https://api.dicebear.com/10.x/glass/svg", + "imageAlt": "Abstract glass avatar illustration", + "imagePosition": "left", + "items": [ + { "title": "First key benefit", "description": "Add a concise explanation of the value this benefit creates for your customers.", "ctaLabel": "", "ctaUrl": "#" }, + { "title": "Second key benefit", "description": "Use a clear, outcome-focused description that is easy to scan.", "ctaLabel": "", "ctaUrl": "#" }, + { "title": "Third key benefit", "description": "Give visitors one more reason to understand and trust your offer.", "ctaLabel": "", "ctaUrl": "#" } + ] + } + }, + { + "type": "stats", + "props": { + "id": "stats", + "heading": "Use a few clear numbers to build confidence.", + "stats": [ + { "value": "10k+", "label": "Customers", "description": "A short note about this result." }, + { "value": "42%", "label": "Growth", "description": "A short note about this result." }, + { "value": "24/7", "label": "Availability", "description": "A short note about this result." }, + { "value": "98%", "label": "Satisfaction", "description": "A short note about this result." } + ] + } + }, + { + "type": "testimonials", + "props": { + "id": "testimonials", + "heading": "Share what customers say about your business.", + "testimonials": [ + { "quote": "Add a short customer quote that explains the value of your work.", "name": "Customer name", "role": "Role, Company", "avatarUrl": "" }, + { "quote": "Use a second testimonial to reinforce the outcome customers can expect.", "name": "Customer name", "role": "Role, Company", "avatarUrl": "" }, + { "quote": "Keep each quote focused, credible, and easy for visitors to scan.", "name": "Customer name", "role": "Role, Company", "avatarUrl": "" } + ] + } + }, + { + "type": "pricing", + "props": { + "id": "pricing", + "heading": "Simple plans for different needs.", + "description": "Replace these sample plans with pricing that fits your business.", + "billingNote": "Prices shown in USD.", + "plans": [ + { "name": "Starter", "price": "$0", "period": "/ month", "description": "A short description of who this plan is for.", "features": "First included feature\nSecond included feature\nThird included feature", "ctaLabel": "Get started", "ctaUrl": "/contact", "badge": "", "highlighted": "no" }, + { "name": "Team", "price": "$24", "period": "/ month", "description": "A short description of who this plan is for.", "features": "Everything in Starter\nAnother useful feature\nPriority support", "ctaLabel": "Get started", "ctaUrl": "/contact", "badge": "Popular", "highlighted": "yes" }, + { "name": "Business", "price": "Custom", "period": "", "description": "A short description of who this plan is for.", "features": "Everything in Team\nAdvanced controls\nDedicated support", "ctaLabel": "Contact us", "ctaUrl": "/contact", "badge": "", "highlighted": "no" } + ] + } + }, + { + "type": "posts-carousel", + "props": { + "id": "posts-carousel", + "heading": "Explore useful articles, guides, and stories.", + "description": "", + "posts": [ + { "title": "Add the title of your first article or resource", "category": "Guide", "date": "August 20, 2026", "imageUrl": "https://api.dicebear.com/10.x/glass/svg", "imageAlt": "Abstract glass avatar illustration", "url": "#", "typeLabel": "Read" }, + { "title": "Share an update that helps customers learn more", "category": "News", "date": "August 14, 2026", "imageUrl": "https://api.dicebear.com/10.x/glass/svg", "imageAlt": "Abstract glass avatar illustration", "url": "#", "typeLabel": "Read" }, + { "title": "Feature a customer story or helpful perspective", "category": "Story", "date": "August 8, 2026", "imageUrl": "https://api.dicebear.com/10.x/glass/svg", "imageAlt": "Abstract glass avatar illustration", "url": "#", "typeLabel": "Read" }, + { "title": "Use additional cards to demonstrate the carousel", "category": "Article", "date": "August 1, 2026", "imageUrl": "https://api.dicebear.com/10.x/glass/svg", "imageAlt": "Abstract glass avatar illustration", "url": "#", "typeLabel": "Read" } + ], + "viewAllLabel": "View all", + "viewAllUrl": "#" + } + }, + { + "type": "faq", + "props": { + "id": "faq", + "heading": "Get answers to frequently asked questions.", + "description": "", + "items": [ + { "question": "What does your business offer?", "answer": "Use this space to provide a clear, concise answer to a common customer question." }, + { "question": "Who is this for?", "answer": "Describe the people, teams, or organizations that will benefit most from your offer." }, + { "question": "How do I get started?", "answer": "Explain the simplest next step and what customers can expect after they begin." }, + { "question": "Where can I learn more?", "answer": "Point visitors toward your contact page, documentation, or another helpful resource." } + ] + } + }, + { + "type": "cta", + "props": { + "id": "cta", + "heading": "Add a clear call to action.", + "description": "Use this space to explain what visitors should do next and why it matters.", + "primaryLabel": "Get started", + "primaryUrl": "/contact", + "secondaryLabel": "", + "secondaryUrl": "/contact" + } + }, + { "type": "footer", "props": { "id": "footer" }, "synced": true } + ] +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..98b9832 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,9 @@ +import PageRender from '@/components/page-render'; +import { pageMetadata } from '@/lib/page-metadata'; +import page from './page.json'; + +export const metadata = pageMetadata(page, { path: '/' }); + +export default function Page() { + return ; +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..26ad91e --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/components/logo.tsx b/components/logo.tsx new file mode 100644 index 0000000..8106c4d --- /dev/null +++ b/components/logo.tsx @@ -0,0 +1,11 @@ +import Link from 'next/link'; + +export function Logo({ name = 'Your Company', logoUrl, href = '/' }: { name?: string; logoUrl?: string; href?: string }) { + const mark = logoUrl ? ( + {name} + ) : ( + {name} + ); + + return {mark}; +} diff --git a/components/page-editor.tsx b/components/page-editor.tsx new file mode 100644 index 0000000..433eee4 --- /dev/null +++ b/components/page-editor.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { useCallback, useMemo } from 'react'; +import { Editor, outlinePlugin, type Data } from '@reacteditor/core'; +import createTailwindCdnPlugin from '@reacteditor/plugin-tailwind-cdn'; +import { appConfig } from '@/editor.config'; +import { publishPage } from '@/lib/publish-page'; +import globals from '@/app.globals.json'; + +const plugins = [outlinePlugin(), createTailwindCdnPlugin()]; + +export default function PageEditor({ pagePath, page }: { pagePath: string; page: Record }) { + const data = useMemo(() => ({ ...page, globals }) as unknown as Data, [page]); + const handlePublish = useCallback(async (published: Data) => { + const result = await publishPage(pagePath, published); + if (result.error) throw new Error(result.error); + }, [pagePath]); + + return ; +} diff --git a/components/page-render.tsx b/components/page-render.tsx new file mode 100644 index 0000000..f421662 --- /dev/null +++ b/components/page-render.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { Render } from '@reacteditor/core/render'; +import { appConfig } from '@/editor.config'; +import globals from '@/app.globals.json'; + +export type PageData = { root?: unknown; content?: unknown; globals?: unknown }; + +export default function PageRender({ page }: { page: PageData }) { + return ; +} diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx new file mode 100644 index 0000000..b0be5ba --- /dev/null +++ b/components/theme-provider.tsx @@ -0,0 +1,8 @@ +'use client'; + +import * as React from 'react'; +import { ThemeProvider as NextThemesProvider } from 'next-themes'; + +export function ThemeProvider({ children, ...props }: React.ComponentProps) { + return {children}; +} diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx new file mode 100644 index 0000000..d00eee1 --- /dev/null +++ b/components/theme-toggle.tsx @@ -0,0 +1,25 @@ +'use client'; + +import * as React from 'react'; +import { Moon, Sun } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { Button } from '@/components/ui/button'; + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => setMounted(true), []); + + return ( + + ); +} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..67ea349 --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,39 @@ +'use client'; + +import * as React from 'react'; +import { ChevronDown } from 'lucide-react'; +import { Accordion as AccordionPrimitive } from 'radix-ui'; +import { cn } from '@/lib/utils'; + +function Accordion(props: React.ComponentProps) { + return ; +} + +function AccordionItem({ className, ...props }: React.ComponentProps) { + return ; +} + +function AccordionTrigger({ className, children, ...props }: React.ComponentProps) { + return ( + + svg]:rotate-180', className)} + {...props} + > + {children} + + + ); +} + +function AccordionContent({ className, children, ...props }: React.ComponentProps) { + return ( + +
{children}
+
+ ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..685c286 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + 'inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-colors outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + outline: 'border bg-background/80 shadow-xs hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-11 rounded-lg px-6', + icon: 'size-9', + }, + }, + defaultVariants: { variant: 'default', size: 'default' }, + } +); + +function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<'button'> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : 'button'; + return ; +} + +export { Button, buttonVariants }; diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..a622953 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +function Card({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/components/ui/carousel.tsx b/components/ui/carousel.tsx new file mode 100644 index 0000000..ff8fe5d --- /dev/null +++ b/components/ui/carousel.tsx @@ -0,0 +1,81 @@ +'use client'; + +import * as React from 'react'; +import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react'; +import { ArrowLeft, ArrowRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +type CarouselContextValue = { + carouselRef: ReturnType[0]; + api: CarouselApi; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; + orientation: 'horizontal' | 'vertical'; +}; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + if (!context) throw new Error('useCarousel must be used within a Carousel'); + return context; +} + +function Carousel({ orientation = 'horizontal', opts, setApi, plugins, className, children, ...props }: React.ComponentProps<'div'> & { orientation?: 'horizontal' | 'vertical'; opts?: CarouselOptions; setApi?: (api: CarouselApi) => void; plugins?: CarouselPlugin }) { + const [carouselRef, api] = useEmblaCarousel({ ...opts, axis: orientation === 'horizontal' ? 'x' : 'y' }, plugins); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((carouselApi: CarouselApi) => { + if (!carouselApi) return; + setCanScrollPrev(carouselApi.canScrollPrev()); + setCanScrollNext(carouselApi.canScrollNext()); + }, []); + const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]); + const scrollNext = React.useCallback(() => api?.scrollNext(), [api]); + + React.useEffect(() => { if (api && setApi) setApi(api); }, [api, setApi]); + React.useEffect(() => { + if (!api) return; + onSelect(api); + api.on('reInit', onSelect); + api.on('select', onSelect); + return () => { api.off('reInit', onSelect); api.off('select', onSelect); }; + }, [api, onSelect]); + + return ( + +
{ if (event.key === 'ArrowLeft') { event.preventDefault(); scrollPrev(); } else if (event.key === 'ArrowRight') { event.preventDefault(); scrollNext(); } }} {...props}>{children}
+
+ ); +} + +function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) { + const { carouselRef, orientation } = useCarousel(); + return
; +} + +function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) { + const { orientation } = useCarousel(); + return
; +} + +function CarouselPrevious({ className, ...props }: React.ComponentProps) { + const { scrollPrev, canScrollPrev } = useCarousel(); + return ; +} + +function CarouselNext({ className, ...props }: React.ComponentProps) { + const { scrollNext, canScrollNext } = useCarousel(); + return ; +} + +export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext }; diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..b30dec1 --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ); +} diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx new file mode 100644 index 0000000..8c502f8 --- /dev/null +++ b/components/ui/textarea.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) { + return ( +