diff --git a/apps/web2/.gitignore b/apps/web2/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/apps/web2/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/web2/README.md b/apps/web2/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/apps/web2/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/web2/components.json b/apps/web2/components.json new file mode 100644 index 0000000..a577707 --- /dev/null +++ b/apps/web2/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/apps/web2/eslint.config.mjs b/apps/web2/eslint.config.mjs new file mode 100644 index 0000000..303708b --- /dev/null +++ b/apps/web2/eslint.config.mjs @@ -0,0 +1,21 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), + { + rules: { + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }] + } + } +]; + +export default eslintConfig; diff --git a/apps/web2/next.config.ts b/apps/web2/next.config.ts new file mode 100644 index 0000000..e9ffa30 --- /dev/null +++ b/apps/web2/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/apps/web2/package.json b/apps/web2/package.json new file mode 100644 index 0000000..8465723 --- /dev/null +++ b/apps/web2/package.json @@ -0,0 +1,43 @@ +{ + "name": "web2", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@monaco-editor/react": "^4.6.0", + "@radix-ui/react-dialog": "^1.1.5", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-select": "^2.1.5", + "@radix-ui/react-slot": "^1.1.1", + "@tinybirdco/mockingbird": "file:../../packages/mockingbird", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "jsoncrush": "^1.1.8", + "lucide-react": "^0.469.0", + "next": "^15.1.6", + "nuqs": "^2.3.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^3.0.1", + "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/node": "^22.13.1", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "autoprefixer": "^10.4.20", + "eslint": "^9.19.0", + "eslint-config-next": "^15.1.6", + "monaco-editor": "^0.52.2", + "postcss": "^8.5.1", + "tailwindcss": "^4.0.3", + "typescript": "^5.7.3" + } +} \ No newline at end of file diff --git a/apps/web2/postcss.config.mjs b/apps/web2/postcss.config.mjs new file mode 100644 index 0000000..1a69fd2 --- /dev/null +++ b/apps/web2/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/apps/web2/public/destinations/ably.svg b/apps/web2/public/destinations/ably.svg new file mode 100644 index 0000000..f57665b --- /dev/null +++ b/apps/web2/public/destinations/ably.svg @@ -0,0 +1,33 @@ + + ably-svg + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web2/public/destinations/awssns.svg b/apps/web2/public/destinations/awssns.svg new file mode 100644 index 0000000..fb4c9b6 --- /dev/null +++ b/apps/web2/public/destinations/awssns.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/apps/web2/public/destinations/tinybird.svg b/apps/web2/public/destinations/tinybird.svg new file mode 100644 index 0000000..012cef6 --- /dev/null +++ b/apps/web2/public/destinations/tinybird.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/web2/public/destinations/upstash-kafka.svg b/apps/web2/public/destinations/upstash-kafka.svg new file mode 100644 index 0000000..88396bd --- /dev/null +++ b/apps/web2/public/destinations/upstash-kafka.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/web2/scripts/shadcn-add.sh b/apps/web2/scripts/shadcn-add.sh new file mode 100755 index 0000000..b58d239 --- /dev/null +++ b/apps/web2/scripts/shadcn-add.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# Add required shadcn components +pnpm dlx shadcn@latest add button +pnpm dlx shadcn@latest add card +pnpm dlx shadcn@latest add select +pnpm dlx shadcn@latest add input +pnpm dlx shadcn@latest add label +pnpm dlx shadcn@latest add alert diff --git a/apps/web2/src/app/favicon.ico b/apps/web2/src/app/favicon.ico new file mode 100644 index 0000000..19b0fcc Binary files /dev/null and b/apps/web2/src/app/favicon.ico differ diff --git a/apps/web2/src/app/generate/page.tsx b/apps/web2/src/app/generate/page.tsx new file mode 100644 index 0000000..8151c47 --- /dev/null +++ b/apps/web2/src/app/generate/page.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { useState, useEffect, Suspense, useRef } from "react"; +import { GenerateStats } from "@/components/generate/generate-stats"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useQueryState } from "nuqs"; +import { destinations } from "@/lib/constants"; +import Image from "next/image"; +import { Settings2, Check } from "lucide-react"; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerFooter, +} from "@/components/ui/drawer"; +import { + validateDestinationConfig, + getDestinationFields, + type DestinationType, +} from "@/lib/types"; +import JSONCrush from "jsoncrush"; +import { createWorker, startWorker, stopWorker } from "@/lib/workerBuilder"; +import type { + TinybirdConfig, + AblyConfig, + AWSSNSConfig, + Schema, +} from "@tinybirdco/mockingbird/client"; + +export default function GeneratePage() { + return ( + + + + ); +} + +function GeneratePageContent() { + const [destination, setDestination] = useQueryState( + "destination", + { + parse: (value) => value as DestinationType, + } + ); + const [config, setConfig] = useQueryState("config", { + parse: (value: string): TinybirdConfig | AblyConfig | AWSSNSConfig => { + const uncrushed = JSONCrush.uncrush(decodeURIComponent(value)); + return JSON.parse(uncrushed); + }, + serialize: (value: object) => { + const stringified = JSON.stringify(value); + return encodeURIComponent(JSONCrush.crush(stringified)); + }, + }); + const [schema] = useQueryState("schema", { + parse: (value: string): Schema => { + const uncrushed = JSONCrush.uncrush(decodeURIComponent(value)); + return JSON.parse(uncrushed); + }, + serialize: (value: object) => { + const stringified = JSON.stringify(value); + return encodeURIComponent(JSONCrush.crush(stringified)); + }, + }); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [formData, setFormData] = useState>({}); + const [formErrors, setFormErrors] = useState>({}); + const [configSaved, setConfigSaved] = useState(false); + const [generatedCount, setGeneratedCount] = useState(0); + const [isGenerating, setIsGenerating] = useState(false); + const workerRef = useRef(null); + + const selectedConfig = destination ? getDestinationFields(destination) : null; + + useEffect(() => { + if (!config || !destination) return; + const fd: Record = {}; + for (const [key, value] of Object.entries(config)) { + fd[key] = value; + handleInputChange(key, value); + } + const result = validateDestinationConfig(destination, fd); + if (result.success) { + setFormErrors({}); + setConfigSaved(true); + } else { + const errors: Record = {}; + result.error.errors.forEach((err) => { + if (err.path) { + errors[err.path[0]] = err.message; + } + }); + setFormErrors(errors); + } + }, [config, destination]); + + const handleConfigSave = () => { + if (!destination || !selectedConfig) return; + + const result = validateDestinationConfig(destination, formData); + + if (result.success) { + setConfig(result.data); + setFormErrors({}); + setIsDrawerOpen(false); + setConfigSaved(true); + } else { + const errors: Record = {}; + result.error.errors.forEach((err) => { + if (err.path) { + errors[err.path[0]] = err.message; + } + }); + setFormErrors(errors); + } + }; + + const handleInputChange = (id: string, value: string) => { + setFormData((prev) => ({ ...prev, [id]: value })); + // Clear error when user starts typing + if (formErrors[id]) { + setFormErrors((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + } + }; + + const handleGenerate = () => { + if (!destination || !config || !schema) return; + + if (isGenerating) { + if (workerRef.current) { + stopWorker(workerRef.current); + workerRef.current = null; + setIsGenerating(false); + } + } else { + setIsGenerating(true); + setGeneratedCount(0); + + const worker = createWorker( + destination, + config, + schema, + (event) => { + console.log(event); + if (typeof event.data === "number") { + setGeneratedCount((prev) => prev + (event.data as number)); + } + }, + (error) => { + console.error("Worker error:", error); + setIsGenerating(false); + } + ); + + if (worker) { + workerRef.current = worker; + startWorker(worker); + } + } + }; + + useEffect(() => { + return () => { + if (workerRef.current) { + stopWorker(workerRef.current); + } + }; + }, []); + + return ( +
+
+
+ + +
+ +
+ +
+
+
+ +
+ + + + + + + Configure {selectedConfig?.name} + +
+ {selectedConfig?.fields.map((field) => ( +
+ + {field.type === "select" && field.options ? ( + + ) : ( + + handleInputChange(field.id, e.target.value) + } + className={formErrors[field.id] ? "border-red-500" : ""} + /> + )} + {formErrors[field.id] && ( +

{formErrors[field.id]}

+ )} +
+ ))} +
+ + + +
+
+
+ ); +} diff --git a/apps/web2/src/app/globals.css b/apps/web2/src/app/globals.css new file mode 100644 index 0000000..942f871 --- /dev/null +++ b/apps/web2/src/app/globals.css @@ -0,0 +1,72 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: Arial, Helvetica, sans-serif; +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 10% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/apps/web2/src/app/layout.tsx b/apps/web2/src/app/layout.tsx new file mode 100644 index 0000000..7649441 --- /dev/null +++ b/apps/web2/src/app/layout.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { MainLayout } from "@/components/layout/main-layout"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; +import "./globals.css"; + +const geistSans = Geist({ + subsets: ["latin"], + variable: "--font-geist-sans", +}); + +const geistMono = Geist_Mono({ + subsets: ["latin"], + variable: "--font-geist-mono", +}); + +export const metadata: Metadata = { + title: "Mockingbird", + description: "Generate mock data with ease", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + {children} + + + + ); +} diff --git a/apps/web2/src/app/page.tsx b/apps/web2/src/app/page.tsx new file mode 100644 index 0000000..276742a --- /dev/null +++ b/apps/web2/src/app/page.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; +import Image from "next/image"; + +export default function Home() { + const router = useRouter(); + + return ( +
+ Mockingbird Logo +

+ Welcome to Mockingbird +

+

+ Generate realistic mock data for your applications. Define your schema, + configure your destination, and start generating data in minutes. +

+ +
+ ); +} diff --git a/apps/web2/src/app/schema/page.tsx b/apps/web2/src/app/schema/page.tsx new file mode 100644 index 0000000..e1cc98e --- /dev/null +++ b/apps/web2/src/app/schema/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { Suspense } from "react"; +import { SchemaEditor } from "@/components/schema/schema-editor"; + +export default function SchemaPage() { + return ( +
+ + + +
+ ); +} diff --git a/apps/web2/src/components/generate/generate-stats.tsx b/apps/web2/src/components/generate/generate-stats.tsx new file mode 100644 index 0000000..0e61b95 --- /dev/null +++ b/apps/web2/src/components/generate/generate-stats.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export function GenerateStats({ + isGenerating = false, + rowsSent = 0, +}: { + isGenerating: boolean; + rowsSent: number; +}) { + return ( +
+ + + + Status + + + +
+ {isGenerating ? "Running" : "Idle"} +
+
+
+ + + + Rows Sent + + + +
{rowsSent.toLocaleString()}
+
+
+
+ ); +} diff --git a/apps/web2/src/components/layout/main-layout.tsx b/apps/web2/src/components/layout/main-layout.tsx new file mode 100644 index 0000000..d5c09e1 --- /dev/null +++ b/apps/web2/src/components/layout/main-layout.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { ReactNode } from "react"; +import { usePathname } from "next/navigation"; +import { Sidebar } from "@/components/layout/sidebar"; + +interface MainLayoutProps { + children: ReactNode; +} + +export function MainLayout({ children }: MainLayoutProps) { + const pathname = usePathname(); + const currentStep = + pathname === "/schema" + ? "schema" + : pathname === "/generate" + ? "generate" + : null; + + return ( +
+
+ +
+ +
{children}
+
+ ); +} diff --git a/apps/web2/src/components/layout/sidebar.tsx b/apps/web2/src/components/layout/sidebar.tsx new file mode 100644 index 0000000..6a0d6b4 --- /dev/null +++ b/apps/web2/src/components/layout/sidebar.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Database, Play } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { type Step } from "@/lib/navigation"; +import { useRouter } from "next/navigation"; + +interface SidebarProps { + currentStep: Step | null; +} + +const steps = [ + { + name: "Define Schema", + step: "schema" as Step, + icon: Database, + }, + { + name: "Generate Data", + step: "generate" as Step, + icon: Play, + }, +]; + +export function Sidebar({ currentStep }: SidebarProps) { + const router = useRouter(); + + const handleStepClick = async (clickedStep: Step) => { + const searchParams = new URLSearchParams(window.location.search); + router.push(`/${clickedStep}?${searchParams.toString()}`); + }; + + return ( +
+
+

Mockingbird

+
+ + +
+ ); +} diff --git a/apps/web2/src/components/schema/schema-editor.tsx b/apps/web2/src/components/schema/schema-editor.tsx new file mode 100644 index 0000000..7955078 --- /dev/null +++ b/apps/web2/src/components/schema/schema-editor.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { Editor, type OnMount } from "@monaco-editor/react"; +import type * as Monaco from "monaco-editor"; +import { useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Alert, + AlertDescription, +} from "@/components/ui/alert"; +import { presetSchemas } from "@tinybirdco/mockingbird/client"; +import { TEMPLATE_OPTIONS } from "@/lib/constants"; +import { useQueryState } from "nuqs"; +import JSONCrush from "jsoncrush"; + +export function SchemaEditor() { + const router = useRouter(); + const editorRef = useRef(null); + const [error, setError] = useState(null); + const [selectedTemplate, setSelectedTemplate] = useState("Custom"); + const [schema, setSchema] = useQueryState("schema", { + parse: (value: string) => { + const uncrushed = JSONCrush.uncrush(decodeURIComponent(value)); + return JSON.parse(uncrushed); + }, + serialize: (value: object) => { + const stringified = JSON.stringify(value); + return encodeURIComponent(JSONCrush.crush(stringified)); + }, + }); + + // Initialize editor value based on template or schema + const getInitialValue = () => { + if (schema) { + return JSON.stringify(schema, null, 2); + } + return "{}"; + }; + + const handleTemplateChange = (templateName: string) => { + setSelectedTemplate(templateName); + + if (templateName === "Custom") { + setSchema({}); + } else { + setSchema(presetSchemas[templateName]); + } + + if (editorRef.current) { + editorRef.current.setValue(templateName === "Custom" ? "{}" : JSON.stringify(presetSchemas[templateName], null, 2)); + } + }; + + const handleEditorDidMount: OnMount = (editor) => { + editorRef.current = editor; + + // Add change listener to detect when user modifies the schema + editor.onDidChangeModelContent(() => { + try { + const value = editor.getValue(); + const parsed = JSON.parse(value); + setSchema(parsed); + } catch (e) { + // If JSON is invalid, don't update the schema + console.error("Invalid JSON:", e); + } + }); + }; + + const handleSave = async () => { + if (!editorRef.current) return; + + const value = editorRef.current.getValue(); + + try { + // Validate JSON + const schemaValue = JSON.parse(value); + await setSchema(schemaValue); + setError(null); + + // Navigate to generate step with search params + const searchParams = new URLSearchParams(window.location.search); + router.push(`/generate?${searchParams.toString()}`); + } catch (e) { + if (e instanceof Error) { + setError("Invalid JSON schema. Please check your syntax. " + e.message); + } else { + setError("Invalid JSON schema. Please check your syntax."); + } + } + }; + + return ( +
+
+ + +
+ + {error && ( + + {error} + + )} + +
+ +
+
+ ); +} diff --git a/apps/web2/src/components/ui/alert.tsx b/apps/web2/src/components/ui/alert.tsx new file mode 100644 index 0000000..5afd41d --- /dev/null +++ b/apps/web2/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/apps/web2/src/components/ui/button.tsx b/apps/web2/src/components/ui/button.tsx new file mode 100644 index 0000000..65d4fcd --- /dev/null +++ b/apps/web2/src/components/ui/button.tsx @@ -0,0 +1,57 @@ +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 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring 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 shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm 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-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/apps/web2/src/components/ui/card.tsx b/apps/web2/src/components/ui/card.tsx new file mode 100644 index 0000000..cabfbfc --- /dev/null +++ b/apps/web2/src/components/ui/card.tsx @@ -0,0 +1,76 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/apps/web2/src/components/ui/drawer.tsx b/apps/web2/src/components/ui/drawer.tsx new file mode 100644 index 0000000..6a0ef53 --- /dev/null +++ b/apps/web2/src/components/ui/drawer.tsx @@ -0,0 +1,118 @@ +"use client" + +import * as React from "react" +import { Drawer as DrawerPrimitive } from "vaul" + +import { cn } from "@/lib/utils" + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) => ( + +) +Drawer.displayName = "Drawer" + +const DrawerTrigger = DrawerPrimitive.Trigger + +const DrawerPortal = DrawerPrimitive.Portal + +const DrawerClose = DrawerPrimitive.Close + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)) +DrawerContent.displayName = "DrawerContent" + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerHeader.displayName = "DrawerHeader" + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerFooter.displayName = "DrawerFooter" + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerTitle.displayName = DrawerPrimitive.Title.displayName + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerDescription.displayName = DrawerPrimitive.Description.displayName + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/apps/web2/src/components/ui/input.tsx b/apps/web2/src/components/ui/input.tsx new file mode 100644 index 0000000..69b64fb --- /dev/null +++ b/apps/web2/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/apps/web2/src/components/ui/label.tsx b/apps/web2/src/components/ui/label.tsx new file mode 100644 index 0000000..5341821 --- /dev/null +++ b/apps/web2/src/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/apps/web2/src/components/ui/select.tsx b/apps/web2/src/components/ui/select.tsx new file mode 100644 index 0000000..0cbf77d --- /dev/null +++ b/apps/web2/src/components/ui/select.tsx @@ -0,0 +1,159 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/apps/web2/src/lib/constants.ts b/apps/web2/src/lib/constants.ts new file mode 100644 index 0000000..45571b5 --- /dev/null +++ b/apps/web2/src/lib/constants.ts @@ -0,0 +1,21 @@ +import { PRESET_SCHEMA_NAMES } from "@tinybirdco/mockingbird/client"; + +export const destinations = [ + { + title: "Tinybird Events API", + generator: "Tinybird", + icon: "/destinations/tinybird.svg", + }, + { + title: "Ably", + generator: "Ably", + icon: "/destinations/ably.svg", + }, + { + title: "AWS SNS", + generator: "AWSSNS", + icon: "/destinations/awssns.svg", + }, +] as const; + +export const TEMPLATE_OPTIONS = [...PRESET_SCHEMA_NAMES, "Custom"] as const; diff --git a/apps/web2/src/lib/navigation.ts b/apps/web2/src/lib/navigation.ts new file mode 100644 index 0000000..ac9c678 --- /dev/null +++ b/apps/web2/src/lib/navigation.ts @@ -0,0 +1,145 @@ +import { destinations } from "./constants"; +import type { AWSSNSConfig, AblyConfig, TinybirdConfig } from '@tinybirdco/mockingbird/client'; +import { z } from 'zod'; + +export type Step = "schema" | "generate"; + +export interface StepState { + currentStep: Step; + destination: string | null; + config: Record | null; + schema: Record | null; +} + +// Zod schemas for config validation +export const tinybirdConfigSchema = z.object({ + endpoint: z.string(), + token: z.string(), + datasource: z.string(), +}); + +export const ablyConfigSchema = z.object({ + channelId: z.string(), + apiKey: z.string(), +}); + +export const awsSNSConfigSchema = z.object({ + region: z.string(), + topicArn: z.string(), + accessKeyId: z.string(), + secretAccessKey: z.string(), +}); + +// Type assertions to ensure our Zod schemas match the Mockingbird types +type ZodTinybirdConfig = z.infer; +type ZodAblyConfig = z.infer; +type ZodAWSSNSConfig = z.infer; + +// These will fail at compile time if our Zod schemas don't match the Mockingbird types +const _tinybirdTypeCheck: ZodTinybirdConfig = {} as TinybirdConfig; +const _ablyTypeCheck: ZodAblyConfig = {} as AblyConfig; +const _awsSNSTypeCheck: ZodAWSSNSConfig = {} as AWSSNSConfig; + +export function validateStepState( + destination: string | null, + config: Record | null, + schema: Record | null +): StepState { + console.log('Validating state:', { destination, config, schema }); + + // Start with a base state + const baseState = { + destination, + config: null, + schema: null, + }; + + // Validate destination + const isValidDestination = destination && destinations.some( + (d) => d.generator === destination + ); + console.log('Destination validation:', { destination, isValidDestination }); + + if (!destination || !isValidDestination) { + return { + ...baseState, + currentStep: "schema", + }; + } + + // Validate config + if (!config) { + return { + ...baseState, + currentStep: "schema", + }; + } + + // Validate config type + const destinationType = destination; + let isValidConfig = false; + + try { + switch (destinationType) { + case 'Ably': + ablyConfigSchema.parse(config); + isValidConfig = true; + break; + case 'AWSSNS': + awsSNSConfigSchema.parse(config); + isValidConfig = true; + break; + case 'Tinybird': + tinybirdConfigSchema.parse(config); + isValidConfig = true; + break; + default: + isValidConfig = false; + } + } catch (e) { + console.error('Config validation error:', e); + isValidConfig = false; + } + + console.log('Config type validation:', { destinationType, isValidConfig }); + + if (!isValidConfig) { + return { + ...baseState, + currentStep: "schema", + }; + } + + // At this point, we have a valid config + const stateWithConfig = { + ...baseState, + config, + }; + + // Validate schema + if (!schema || typeof schema !== 'object') { + return { + ...stateWithConfig, + currentStep: "schema", + }; + } + + // Check if schema is empty (empty object) + const isEmptySchema = Object.keys(schema).length === 0; + console.log('Schema validation:', { schema, isEmptySchema }); + + if (isEmptySchema) { + return { + ...stateWithConfig, + currentStep: "schema", + schema: null, + }; + } + + // If we have a valid schema, we can proceed to generate + return { + ...stateWithConfig, + currentStep: "generate", + schema, + }; +} diff --git a/apps/web2/src/lib/types.ts b/apps/web2/src/lib/types.ts new file mode 100644 index 0000000..23597f9 --- /dev/null +++ b/apps/web2/src/lib/types.ts @@ -0,0 +1,154 @@ +import { z } from "zod"; +import { + type AWSSNSConfig, + type AblyConfig, + type TinybirdConfig, +} from "@tinybirdco/mockingbird/client"; + +export type DestinationType = "Tinybird" | "AWSSNS" | "Ably"; +export type DestinationConfig = AWSSNSConfig | AblyConfig | TinybirdConfig; + +enum tinybirdEndpoints { + gcp_europe_west3, + gcp_us_east4, + aws_us_east_1, + aws_eu_central_1, + aws_us_west_2, +} + +// Validation schemas +export const tinybirdConfigSchema = z.object({ + token: z.string().min(1, "API Token is required"), + datasource: z.string().min(1, "Datasource Name is required"), + endpoint: z.enum( + Object.keys(tinybirdEndpoints) as [keyof typeof tinybirdEndpoints] + ), +}); + +export const awsSNSConfigSchema = z.object({ + accessKeyId: z.string().min(1, "Access Key ID is required"), + secretAccessKey: z.string().min(1, "Secret Access Key is required"), + region: z.string().min(1, "Region is required"), + topicArn: z.string().min(1, "Topic ARN is required"), +}); + +export const ablyConfigSchema = z.object({ + apiKey: z.string().min(1, "API Key is required"), + channelId: z.string().min(1, "Channel ID is required"), +}); + +// Field configurations +interface ConfigField { + id: keyof (AWSSNSConfig & AblyConfig & TinybirdConfig); + label: string; + type?: string; + required?: boolean; + options?: { + label: string; + value: string; + }[]; +} + +interface DestinationFields { + name: string; + fields: ConfigField[]; + schema: z.ZodObject; +} + +export const destinationFields: Record = { + Tinybird: { + name: "Tinybird", + fields: [ + { + id: "token", + label: "API Token", + required: true, + type: "input", + }, + { + id: "datasource", + label: "Datasource Name", + required: true, + type: "input", + }, + { + id: "endpoint", + label: "Endpoint", + required: true, + type: "select", + options: [ + { label: "GCP Europe West 3", value: "gcp_europe_west3" }, + { label: "GCP US East 4", value: "gcp_us_east4" }, + { label: "AWS US East 1", value: "aws_us_east_1" }, + { label: "AWS EU Central 1", value: "aws_eu_central_1" }, + { label: "AWS US West 2", value: "aws_us_west_2" }, + ], + }, + ], + schema: tinybirdConfigSchema, + }, + AWSSNS: { + name: "AWS SNS", + fields: [ + { + id: "accessKeyId", + label: "Access Key ID", + required: true, + type: "input", + }, + { + id: "secretAccessKey", + label: "Secret Access Key", + required: true, + type: "input", + }, + { + id: "region", + label: "Region", + required: true, + type: "input", + }, + { + id: "topicArn", + label: "Topic ARN", + required: true, + type: "input", + }, + ], + schema: awsSNSConfigSchema, + }, + Ably: { + name: "Ably", + fields: [ + { + id: "apiKey", + label: "API Key", + required: true, + type: "input", + }, + { + id: "channelId", + label: "Channel Name", + required: true, + type: "input", + }, + ], + schema: ablyConfigSchema, + }, +}; + +// Validation helper functions +export function validateDestinationConfig( + destination: DestinationType, + config: unknown +): z.SafeParseReturnType { + const schema = destinationFields[destination].schema; + return schema.safeParse(config) as z.SafeParseReturnType< + unknown, + DestinationConfig + >; +} + +export function getDestinationFields(destination: DestinationType) { + return destinationFields[destination]; +} diff --git a/apps/web2/src/lib/utils.ts b/apps/web2/src/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/apps/web2/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/apps/web2/src/lib/worker.ts b/apps/web2/src/lib/worker.ts new file mode 100644 index 0000000..664f5f2 --- /dev/null +++ b/apps/web2/src/lib/worker.ts @@ -0,0 +1,42 @@ +import { MockingbirdGenerator } from "@tinybirdco/mockingbird/client"; +import type { DestinationType } from "./types"; + +import { + TinybirdGenerator, + AblyGenerator, + AWSSNSGenerator, +} from "@tinybirdco/mockingbird/client"; + +let generator: MockingbirdGenerator; + +const nameToGenerator = { + Tinybird: TinybirdGenerator, + Ably: AblyGenerator, + AWSSNS: AWSSNSGenerator, +} as const; + +onmessage = async function (e) { + try { + if ("init" in e.data) { + const destination = e.data.destination as DestinationType; + if ("config" in e.data) { + generator = new nameToGenerator[destination]({ + ...e.data.config, + schema: e.data.init, + }); + } else { + throw new Error("No config supplied to worker"); + } + } else if (generator) { + await generator.generate((data) => { + try { + self.postMessage(data.length); + } catch (error) { + self.postMessage({ error: `Error posting message: ${error}` }); + } + }); + } + } catch (error) { + self.postMessage({ error: `Worker error: ${error}` }); + } +}; diff --git a/apps/web2/src/lib/workerBuilder.ts b/apps/web2/src/lib/workerBuilder.ts new file mode 100644 index 0000000..a692c91 --- /dev/null +++ b/apps/web2/src/lib/workerBuilder.ts @@ -0,0 +1,54 @@ +import type { DestinationType } from "./types"; +import type { + TinybirdConfig, + AblyConfig, + AWSSNSConfig, + Schema, +} from "@tinybirdco/mockingbird/client"; + +export function createWorker( + destination: DestinationType, + config: TinybirdConfig | AblyConfig | AWSSNSConfig, + schema: Schema, + onMessage?: (message: MessageEvent) => void, + onError?: (e: ErrorEvent) => void +) { + if (typeof window === "undefined" || !window.Worker) return null; + console.log(schema); + const worker = new Worker(new URL("./worker.ts", import.meta.url), { + type: "module", + }); + + if (onMessage) { + worker.onmessage = (event) => { + if (typeof event.data === "object" && "error" in event.data) { + console.error("Worker error:", event.data.error); + if (onError) + onError( + new ErrorEvent("error", { error: new Error(event.data.error) }) + ); + } else { + onMessage(event); + } + }; + } + if (onError) worker.onerror = onError; + + worker.postMessage({ + init: schema, + destination, + config, + }); + + return worker; +} + +export function startWorker(worker: Worker) { + console.log("starting worker"); + worker.postMessage({ start: true }); +} + +export function stopWorker(worker: Worker) { + console.log("stopping worker"); + worker.terminate(); +} diff --git a/apps/web2/tailwind.config.ts b/apps/web2/tailwind.config.ts new file mode 100644 index 0000000..266c06d --- /dev/null +++ b/apps/web2/tailwind.config.ts @@ -0,0 +1,62 @@ +import type { Config } from "tailwindcss"; + +export default { + darkMode: ["class"], + content: [ + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))' + } + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + } + } + }, + plugins: [require("tailwindcss-animate")], +} satisfies Config; diff --git a/apps/web2/tsconfig.json b/apps/web2/tsconfig.json new file mode 100644 index 0000000..c133409 --- /dev/null +++ b/apps/web2/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}