From 8a50e4fe5644910ed2601404467a2a27d4852f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:56:49 +0100 Subject: [PATCH] feat(web): create-mesh form + invite-link generator with QR code - create-mesh-form: RHF + zod + shadcn Form. Fields name, slug (auto- derived from name, editable), visibility, transport. Slug validation matches server (lowercase letters, digits, hyphens). Slug collision errors surface on the slug field. - invite-generator: RHF + zod. Fields role, maxUses, expiresInDays. After generation: renders the ic://join/... invite link as a 256px QR code (PNG data URL, Claude-palette colors) + copy-to-clipboard button + "claudemesh join " snippet for teammates. Add: qrcode 1.5.4 + @types/qrcode 1.5.5 (QR generation runs client-side). Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/package.json | 2 + .../web/src/modules/mesh/create-mesh-form.tsx | 177 ++++++++++++++ .../web/src/modules/mesh/invite-generator.tsx | 227 ++++++++++++++++++ 3 files changed, 406 insertions(+) create mode 100644 apps/web/src/modules/mesh/create-mesh-form.tsx create mode 100644 apps/web/src/modules/mesh/invite-generator.tsx diff --git a/apps/web/package.json b/apps/web/package.json index b9ac9ab..3191850 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -45,6 +45,7 @@ "next-themes": "0.4.6", "nuqs": "2.7.2", "pdfjs-dist": "5.4.530", + "qrcode": "1.5.4", "react": "catalog:react19", "react-dom": "catalog:react19", "react-dropzone": "14.3.8", @@ -67,6 +68,7 @@ "@turbostarter/prettier-config": "workspace:*", "@turbostarter/tsconfig": "workspace:*", "@types/node": "catalog:node22", + "@types/qrcode": "1.5.6", "@types/react": "catalog:react19", "@types/react-dom": "catalog:react19", "autoprefixer": "10.4.21", diff --git a/apps/web/src/modules/mesh/create-mesh-form.tsx b/apps/web/src/modules/mesh/create-mesh-form.tsx new file mode 100644 index 0000000..c252172 --- /dev/null +++ b/apps/web/src/modules/mesh/create-mesh-form.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { + createMyMeshInputSchema, + type CreateMyMeshInput, +} from "@turbostarter/api/schema"; +import { handle } from "@turbostarter/api/utils"; +import { Button } from "@turbostarter/ui-web/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@turbostarter/ui-web/form"; +import { Input } from "@turbostarter/ui-web/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@turbostarter/ui-web/select"; + +import { pathsConfig } from "~/config/paths"; +import { api } from "~/lib/api/client"; + +const slugify = (s: string) => + s + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); + +export const CreateMeshForm = () => { + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(createMyMeshInputSchema), + defaultValues: { + name: "", + slug: "", + visibility: "private", + transport: "managed", + }, + }); + + const nameValue = form.watch("name"); + const slugDirty = form.formState.dirtyFields.slug; + + useEffect(() => { + if (!slugDirty && nameValue) { + form.setValue("slug", slugify(nameValue)); + } + }, [nameValue, slugDirty, form]); + + const onSubmit = async (values: CreateMyMeshInput) => { + try { + const res = (await handle(api.my.meshes.$post)({ + json: values, + })) as { id: string; slug: string } | { error: string }; + if ("error" in res) { + form.setError("slug", { message: res.error }); + return; + } + router.push(pathsConfig.dashboard.user.meshes.mesh(res.id)); + } catch (e) { + form.setError("root", { + message: e instanceof Error ? e.message : "Failed to create mesh.", + }); + } + }; + + return ( + + + ( + + Name + + + + + Display name — what teammates see. + + + + )} + /> + ( + + Slug + + + + + URL-safe identifier: lowercase letters, digits, hyphens. + + + + )} + /> + ( + + Visibility + + + + + + + + + Private — invite-only + + + Public — anyone with the link + + + + + + )} + /> + ( + + Transport + + + + + + + + Managed (claudemesh.com) + Tailscale + Self-hosted broker + + + + How peers reach the broker. + + + + )} + /> + {form.formState.errors.root && ( + + {form.formState.errors.root.message} + + )} + + {form.formState.isSubmitting ? "Creating…" : "Create mesh"} + + + + ); +}; diff --git a/apps/web/src/modules/mesh/invite-generator.tsx b/apps/web/src/modules/mesh/invite-generator.tsx new file mode 100644 index 0000000..dde0d45 --- /dev/null +++ b/apps/web/src/modules/mesh/invite-generator.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import QRCode from "qrcode"; + +import { + createMyInviteInputSchema, + type CreateMyInviteInput, +} from "@turbostarter/api/schema"; +import { handle } from "@turbostarter/api/utils"; +import { Badge } from "@turbostarter/ui-web/badge"; +import { Button } from "@turbostarter/ui-web/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@turbostarter/ui-web/form"; +import { Input } from "@turbostarter/ui-web/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@turbostarter/ui-web/select"; + +import { api } from "~/lib/api/client"; + +interface GeneratedInvite { + id: string; + token: string; + inviteLink: string; + expiresAt: Date; + qrDataUrl: string; +} + +export const InviteGenerator = ({ meshId }: { meshId: string }) => { + const [result, setResult] = useState(null); + const [copied, setCopied] = useState(false); + + const form = useForm({ + resolver: zodResolver(createMyInviteInputSchema), + defaultValues: { role: "member", maxUses: 1, expiresInDays: 7 }, + }); + + const onSubmit = async (values: CreateMyInviteInput) => { + try { + const res = (await handle(api.my.meshes[":id"].invites.$post)({ + param: { id: meshId }, + json: values, + })) as + | { + id: string; + token: string; + inviteLink: string; + expiresAt: string; + } + | { error: string }; + + if ("error" in res) { + form.setError("root", { message: res.error }); + return; + } + + const qrDataUrl = await QRCode.toDataURL(res.inviteLink, { + width: 256, + margin: 1, + color: { dark: "#141413", light: "#ffffff" }, + }); + + setResult({ + id: res.id, + token: res.token, + inviteLink: res.inviteLink, + expiresAt: new Date(res.expiresAt), + qrDataUrl, + }); + } catch (e) { + form.setError("root", { + message: e instanceof Error ? e.message : "Failed to generate invite.", + }); + } + }; + + const onCopy = async () => { + if (!result) return; + await navigator.clipboard.writeText(result.inviteLink); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + if (result) { + return ( + + + + + + + + + + Invite link + + + {result.inviteLink} + + + + + expires {result.expiresAt.toLocaleDateString()} + + + + + {copied ? "Copied ✓" : "Copy link"} + + { + setResult(null); + form.reset(); + }} + > + Generate another + + + + + + + How your teammate joins: + + claudemesh join {result.inviteLink} + + + Or scan the QR code from the claudemesh mobile app (coming soon). + + + + ); + } + + return ( + + + ( + + Role + + + + + + + + Member + Admin + + + + + )} + /> + ( + + Max uses + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + ( + + Expires in (days) + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + {form.formState.errors.root && ( + + {form.formState.errors.root.message} + + )} + + {form.formState.isSubmitting ? "Generating…" : "Generate invite"} + + + + ); +};
+ {form.formState.errors.root.message} +
+ {result.inviteLink} +
How your teammate joins:
+ claudemesh join {result.inviteLink} +
+ Or scan the QR code from the claudemesh mobile app (coming soon). +