Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/platform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next

This app is configured to deploy to Cloudflare Workers via OpenNext.

```bash
npm run build
```

Run a plain Next.js production build.

```bash
npm run build:cloudflare
```

Build the Cloudflare Worker bundle with OpenNext.

```bash
npm run preview
```
Expand All @@ -45,6 +57,12 @@ npm run deploy

Build and deploy to Cloudflare.

In Cloudflare, set the build command to:

```bash
pnpm run build:cloudflare
```

### One-time setup

```bash
Expand Down
2 changes: 1 addition & 1 deletion apps/platform/app/(docs)/installation/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PageHeader, SubTitle, Title } from "@/components/core/typography";
import { PageHeader } from "@/components/core/typography";
import { CodeBlock } from "@/components/showcase/code-block";
import MotionDiv from "@/components/core/motion-div";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,13 @@ const buttonVariants = cva(
},
);

type CopyButtonProps = Omit<ButtonPrimitiveProps, 'children'> &
type CopyButtonProps = Omit<ButtonPrimitiveProps, 'children' | 'asChild'> &
VariantProps<typeof buttonVariants> & {
content: string;
copied?: boolean;
onCopiedChange?: (copied: boolean, content?: string) => void;
delay?: number;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
};

function CopyButton({
Expand Down Expand Up @@ -95,10 +96,11 @@ function CopyButton({

return (
<ButtonPrimitive
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(props as any)}
data-slot="copy-button"
className={cn(buttonVariants({ variant, size, className }))}
onClick={handleCopy}
{...props}
>
<AnimatePresence mode="popLayout">
<motion.span
Expand Down
45 changes: 31 additions & 14 deletions apps/platform/components/animate-ui/primitives/animate/slot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,31 +58,48 @@ function mergeProps<T extends HTMLElement>(
return merged;
}

const motionCache = new Map<React.ElementType, React.ElementType>();

function Slot<T extends HTMLElement = HTMLElement>({
children,
ref,
...props
}: SlotProps<T>) {
const isAlreadyMotion =
typeof children.type === 'object' &&
children.type !== null &&
isMotionComponent(children.type);

const Base = React.useMemo(
() =>
isAlreadyMotion
? (children.type as React.ElementType)
: motion.create(children.type as React.ElementType),
[isAlreadyMotion, children.type],
);
const isAlreadyMotion = React.useMemo(() => {
if (!React.isValidElement(children)) return false;
return (
typeof children.type === 'object' &&
children.type !== null &&
isMotionComponent(children.type)
);
}, [children]);

if (!React.isValidElement(children)) return null;
const Base = React.useMemo(() => {
if (!React.isValidElement(children)) return null;

const { ref: childRef, ...childProps } = children.props as AnyProps;
if (isAlreadyMotion) return children.type as React.ElementType;

if (typeof children.type === 'string') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (motion as any)[children.type] as React.ElementType;
}

const type = children.type as React.ElementType;
let Component = motionCache.get(type);
if (!Component) {
Component = motion.create(type);
motionCache.set(type, Component);
}
return Component;
}, [isAlreadyMotion, children]);

if (!React.isValidElement(children) || !Base) return null;

const { ref: childRef, ...childProps } = children.props as AnyProps;
const mergedProps = mergeProps(childProps, props);

return (
// eslint-disable-next-line react-hooks/static-components
<Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />
);
}
Expand Down
39 changes: 24 additions & 15 deletions apps/platform/components/animate-ui/primitives/buttons/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,30 @@ type ButtonProps = WithAsChild<
}
>;

function Button({
hoverScale = 1.05,
tapScale = 0.95,
asChild = false,
...props
}: ButtonProps) {
const Component = asChild ? Slot : motion.button;
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
hoverScale = 1.05,
tapScale = 0.95,
asChild = false,
...props
}: ButtonProps,
ref,
) => {
const Component = asChild ? Slot : motion.button;

return (
<Component
whileTap={{ scale: tapScale }}
whileHover={{ scale: hoverScale }}
{...props}
/>
);
}
return (
<Component
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref={ref as any}
whileTap={{ scale: tapScale }}
whileHover={{ scale: hoverScale }}
{...props}
/>
);
},
);

Button.displayName = 'Button';

export { Button, type ButtonProps };
11 changes: 0 additions & 11 deletions apps/platform/components/showcase/code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ export function CodeBlock({
title,
mobile = false,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const [syntax, setSyntax] = useState<{
Expand Down Expand Up @@ -140,16 +139,6 @@ export function CodeBlock({
};
}, []);

async function handleCopy() {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch {
setCopied(false);
}
}

return (
<div
className={cn(
Expand Down
1 change: 0 additions & 1 deletion apps/platform/components/showcase/component-docs.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Link from "next/link";
import { PreviewVideo } from "@/components/showcase/docs-primitives";
import { Button } from "@/components/ui/button";
import { getRegistryCatalog } from "@/lib/registry-catalog";
import Image from "next/image";
Expand Down
6 changes: 4 additions & 2 deletions apps/platform/components/ui/iphone.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { HTMLAttributes } from "react"
import Image from "next/image"

const PHONE_WIDTH = 433
const PHONE_HEIGHT = 882
Expand Down Expand Up @@ -74,10 +75,11 @@ export function Iphone({
borderRadius: `${RADIUS_H}% / ${RADIUS_V}%`,
}}
>
<img
<Image
src={src}
alt=""
className="block size-full object-cover object-top"
fill
className="object-cover object-top"
/>
</div>
)}
Expand Down
79 changes: 24 additions & 55 deletions apps/platform/lib/registry-catalog.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,5 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { COMPONENT_DOC_META } from "@/lib/component-index";

type RegistryComponentRecord = {
name: string;
type: string;
files: Array<{
path: string;
type: string;
}>;
dependencies?: string[];
registryDependencies?: string[];
};

type RegistryFile = {
components: Record<string, RegistryComponentRecord>;
};
import registryData from "./registry-data.json";

export type ShowcaseComponent = {
slug: string;
Expand All @@ -42,45 +26,30 @@ function titleCase(value: string) {
}

export async function getRegistryCatalog(): Promise<ShowcaseCategory[]> {
const registryRoot = path.join(
process.cwd(),
"..",
"..",
"packages",
"registry",
);
const registryPath = path.join(registryRoot, "registry.json");
const registryRaw = await readFile(registryPath, "utf8");
const registry = JSON.parse(registryRaw) as RegistryFile;

const items = await Promise.all(
Object.entries(registry.components).map(async ([slug, component]) => {
const preset = COMPONENT_DOC_META[slug] ?? {
title: titleCase(component.name),
description: "Reusable React Native component from the Watermelon registry.",
category: "Components",
usage: `watermelon add ${slug}`,
preview: "button" as const,
};
const primaryFile = component.files[0];
const sourcePath = path.join(registryRoot, "src", primaryFile.path);
const source = await readFile(sourcePath, "utf8");
const items = registryData.map((component) => {
const slug = component.slug;
const preset = COMPONENT_DOC_META[slug] ?? {
title: titleCase(component.name),
description: "Reusable React Native component from the Watermelon registry.",
category: "Components",
usage: `watermelon add ${slug}`,
preview: "button" as const,
};

return {
slug,
title: preset.title,
description: preset.description,
category: preset.category,
dependencies: component.dependencies ?? [],
registryDependencies: component.registryDependencies ?? [],
installCommand: `watermelon add ${slug}`,
sourcePath: primaryFile.path,
source,
usage: preset.usage,
preview: preset.preview,
} satisfies ShowcaseComponent;
}),
);
return {
slug,
title: preset.title,
description: preset.description,
category: preset.category,
dependencies: component.dependencies ?? [],
registryDependencies: component.registryDependencies ?? [],
installCommand: `watermelon add ${slug}`,
sourcePath: component.sourcePath,
source: component.source,
usage: preset.usage,
preview: preset.preview,
} satisfies ShowcaseComponent;
});

const grouped = items.reduce<Map<string, ShowcaseComponent[]>>((acc, item) => {
const bucket = acc.get(item.category) ?? [];
Expand Down
28 changes: 28 additions & 0 deletions apps/platform/lib/registry-data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[
{
"slug": "button",
"name": "button",
"dependencies": [
"@rn-primitives/slot",
"class-variance-authority",
"lucide-react-native",
"react-native-svg"
],
"registryDependencies": [
"text"
],
"sourcePath": "components/ui/button.tsx",
"source": "import { TextClassContext } from '@/registry/components/ui/text';\nimport { cn } from '@/registry/lib/utils';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { Platform, Pressable } from 'react-native';\nimport * as React from 'react';\n\nconst buttonVariants = cva(\n cn(\n 'group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none',\n Platform.select({\n web: \"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n })\n ),\n {\n variants: {\n variant: {\n default: cn(\n 'bg-primary active:bg-primary/90 shadow-sm shadow-black/5',\n Platform.select({ web: 'hover:bg-primary/90' })\n ),\n destructive: cn(\n 'bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm shadow-black/5',\n Platform.select({\n web: 'hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',\n })\n ),\n outline: cn(\n 'border-border bg-background active:bg-accent dark:bg-input/30 dark:border-input dark:active:bg-input/50 border shadow-sm shadow-black/5',\n Platform.select({\n web: 'hover:bg-accent dark:hover:bg-input/50',\n })\n ),\n secondary: cn(\n 'bg-secondary active:bg-secondary/80 shadow-sm shadow-black/5',\n Platform.select({ web: 'hover:bg-secondary/80' })\n ),\n ghost: cn(\n 'active:bg-accent dark:active:bg-accent/50',\n Platform.select({ web: 'hover:bg-accent dark:hover:bg-accent/50' })\n ),\n link: '',\n },\n size: {\n default: cn('h-10 px-4 py-2 sm:h-9', Platform.select({ web: 'has-[>svg]:px-3' })),\n sm: cn('h-9 gap-1.5 rounded-md px-3 sm:h-8', Platform.select({ web: 'has-[>svg]:px-2.5' })),\n lg: cn('h-11 rounded-md px-6 sm:h-10', Platform.select({ web: 'has-[>svg]:px-4' })),\n icon: 'h-10 w-10 sm:h-9 sm:w-9',\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n }\n);\n\nconst buttonTextVariants = cva(\n cn(\n 'text-foreground text-sm font-medium',\n Platform.select({ web: 'pointer-events-none transition-colors' })\n ),\n {\n variants: {\n variant: {\n default: 'text-primary-foreground',\n destructive: 'text-white',\n outline: cn(\n 'group-active:text-accent-foreground',\n Platform.select({ web: 'group-hover:text-accent-foreground' })\n ),\n secondary: 'text-secondary-foreground',\n ghost: 'group-active:text-accent-foreground',\n link: cn(\n 'text-primary group-active:underline',\n Platform.select({ web: 'underline-offset-4 hover:underline group-hover:underline' })\n ),\n },\n size: {\n default: '',\n sm: '',\n lg: '',\n icon: '',\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n }\n);\n\ntype ButtonProps = React.ComponentProps<typeof Pressable> &\n React.RefAttributes<typeof Pressable> &\n VariantProps<typeof buttonVariants>;\n\nfunction Button({ className, variant, size, ...props }: ButtonProps) {\n return (\n <TextClassContext.Provider value={buttonTextVariants({ variant, size })}>\n <Pressable\n className={cn(props.disabled && 'opacity-50', buttonVariants({ variant, size }), className)}\n role=\"button\"\n {...props}\n />\n </TextClassContext.Provider>\n );\n}\n\nexport { Button, buttonTextVariants, buttonVariants };\nexport type { ButtonProps };\n"
},
{
"slug": "text",
"name": "text",
"dependencies": [
"@rn-primitives/slot",
"class-variance-authority"
],
"registryDependencies": [],
"sourcePath": "components/ui/text.tsx",
"source": "import { cn } from '@/registry/lib/utils';\nimport * as Slot from '@rn-primitives/slot';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport * as React from 'react';\nimport { Platform, Text as RNText, type Role } from 'react-native';\n\nconst textVariants = cva(\n cn(\n 'text-foreground text-base',\n Platform.select({\n web: 'select-text',\n })\n ),\n {\n variants: {\n variant: {\n default: '',\n h1: cn(\n 'text-center text-4xl font-extrabold tracking-tight',\n Platform.select({ web: 'scroll-m-20 text-balance' })\n ),\n h2: cn(\n 'border-border border-b pb-2 text-3xl font-semibold tracking-tight',\n Platform.select({ web: 'scroll-m-20 first:mt-0' })\n ),\n h3: cn('text-2xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),\n h4: cn('text-xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),\n p: 'mt-3 leading-7 sm:mt-6',\n blockquote: 'mt-4 border-l-2 pl-3 italic sm:mt-6 sm:pl-6',\n code: cn(\n 'bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold'\n ),\n lead: 'text-muted-foreground text-xl',\n large: 'text-lg font-semibold',\n small: 'text-sm font-medium leading-none',\n muted: 'text-muted-foreground text-sm',\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n }\n);\n\ntype TextVariantProps = VariantProps<typeof textVariants>;\n\ntype TextVariant = NonNullable<TextVariantProps['variant']>;\n\nconst ROLE: Partial<Record<TextVariant, Role>> = {\n h1: 'heading',\n h2: 'heading',\n h3: 'heading',\n h4: 'heading',\n blockquote: Platform.select({ web: 'blockquote' as Role }),\n code: Platform.select({ web: 'code' as Role }),\n};\n\nconst ARIA_LEVEL: Partial<Record<TextVariant, string>> = {\n h1: '1',\n h2: '2',\n h3: '3',\n h4: '4',\n};\n\nconst TextClassContext = React.createContext<string | undefined>(undefined);\n\nfunction Text({\n className,\n asChild = false,\n variant = 'default',\n ...props\n}: React.ComponentProps<typeof RNText> &\n TextVariantProps &\n React.RefAttributes<RNText> & {\n asChild?: boolean;\n }) {\n const textClass = React.useContext(TextClassContext);\n const Component = asChild ? Slot.Text : RNText;\n return (\n <Component\n className={cn(textVariants({ variant }), textClass, className)}\n role={variant ? ROLE[variant] : undefined}\n aria-level={variant ? ARIA_LEVEL[variant] : undefined}\n {...props}\n />\n );\n}\n\nexport { Text, TextClassContext };\n"
}
]
8 changes: 5 additions & 3 deletions apps/platform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
"private": true,
"scripts": {
"dev": "next dev",
"prebuild": "node scripts/generate-registry.mjs",
"build": "next build",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
"build:cloudflare": "opennextjs-cloudflare build",
"preview": "pnpm run build:cloudflare && opennextjs-cloudflare preview",
"deploy": "pnpm run build:cloudflare && opennextjs-cloudflare deploy",
"upload": "pnpm run build:cloudflare && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
"start": "next start",
"lint": "eslint"
Expand Down
35 changes: 35 additions & 0 deletions apps/platform/scripts/generate-registry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";

async function generateRegistryData() {
const root = process.cwd();
const registryRoot = path.join(root, "..", "..", "packages", "registry");
const registryPath = path.join(registryRoot, "registry.json");

console.log("Reading registry from:", registryPath);
const registryRaw = await readFile(registryPath, "utf8");
const registry = JSON.parse(registryRaw);

const items = await Promise.all(
Object.entries(registry.components).map(async ([slug, component]) => {
const primaryFile = component.files[0];
const sourcePath = path.join(registryRoot, "src", primaryFile.path);
const source = await readFile(sourcePath, "utf8");

return {
slug,
name: component.name,
dependencies: component.dependencies ?? [],
registryDependencies: component.registryDependencies ?? [],
sourcePath: primaryFile.path,
source,
};
})
);

const outputPath = path.join(root, "lib", "registry-data.json");
await writeFile(outputPath, JSON.stringify(items, null, 2));
console.log("Generated registry data to:", outputPath);
}

generateRegistryData().catch(console.error);
Loading
Loading