feat(web): create-mesh form + invite-link generator with QR code
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
- 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 <link>" 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) <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,7 @@
|
|||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nuqs": "2.7.2",
|
"nuqs": "2.7.2",
|
||||||
"pdfjs-dist": "5.4.530",
|
"pdfjs-dist": "5.4.530",
|
||||||
|
"qrcode": "1.5.4",
|
||||||
"react": "catalog:react19",
|
"react": "catalog:react19",
|
||||||
"react-dom": "catalog:react19",
|
"react-dom": "catalog:react19",
|
||||||
"react-dropzone": "14.3.8",
|
"react-dropzone": "14.3.8",
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
"@turbostarter/prettier-config": "workspace:*",
|
"@turbostarter/prettier-config": "workspace:*",
|
||||||
"@turbostarter/tsconfig": "workspace:*",
|
"@turbostarter/tsconfig": "workspace:*",
|
||||||
"@types/node": "catalog:node22",
|
"@types/node": "catalog:node22",
|
||||||
|
"@types/qrcode": "1.5.6",
|
||||||
"@types/react": "catalog:react19",
|
"@types/react": "catalog:react19",
|
||||||
"@types/react-dom": "catalog:react19",
|
"@types/react-dom": "catalog:react19",
|
||||||
"autoprefixer": "10.4.21",
|
"autoprefixer": "10.4.21",
|
||||||
|
|||||||
177
apps/web/src/modules/mesh/create-mesh-form.tsx
Normal file
177
apps/web/src/modules/mesh/create-mesh-form.tsx
Normal file
@@ -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<CreateMyMeshInput>({
|
||||||
|
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 (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Platform team" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Display name — what teammates see.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Slug</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="platform-team" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
URL-safe identifier: lowercase letters, digits, hyphens.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="visibility"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Visibility</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="private">
|
||||||
|
Private — invite-only
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="public">
|
||||||
|
Public — anyone with the link
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="transport"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Transport</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="managed">Managed (claudemesh.com)</SelectItem>
|
||||||
|
<SelectItem value="tailscale">Tailscale</SelectItem>
|
||||||
|
<SelectItem value="self_hosted">Self-hosted broker</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
How peers reach the broker.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{form.formState.errors.root && (
|
||||||
|
<p className="text-destructive text-sm">
|
||||||
|
{form.formState.errors.root.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? "Creating…" : "Create mesh"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
227
apps/web/src/modules/mesh/invite-generator.tsx
Normal file
227
apps/web/src/modules/mesh/invite-generator.tsx
Normal file
@@ -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<GeneratedInvite | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<CreateMyInviteInput>({
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="rounded-lg border p-6">
|
||||||
|
<div className="grid gap-6 md:grid-cols-[220px_1fr]">
|
||||||
|
<div className="flex items-start justify-center">
|
||||||
|
<img
|
||||||
|
src={result.qrDataUrl}
|
||||||
|
alt="Invite QR code"
|
||||||
|
className="h-[220px] w-[220px] rounded border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground mb-1 text-xs uppercase tracking-wider">
|
||||||
|
Invite link
|
||||||
|
</div>
|
||||||
|
<code className="bg-muted block break-all rounded p-3 font-mono text-xs">
|
||||||
|
{result.inviteLink}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs">
|
||||||
|
<Badge variant="outline">
|
||||||
|
expires {result.expiresAt.toLocaleDateString()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={onCopy} size="sm">
|
||||||
|
{copied ? "Copied ✓" : "Copy link"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setResult(null);
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Generate another
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground rounded border border-dashed p-4 text-xs">
|
||||||
|
<p className="mb-2 font-medium">How your teammate joins:</p>
|
||||||
|
<code className="bg-muted block rounded p-2 font-mono text-xs">
|
||||||
|
claudemesh join {result.inviteLink}
|
||||||
|
</code>
|
||||||
|
<p className="mt-2">
|
||||||
|
Or scan the QR code from the claudemesh mobile app (coming soon).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="max-w-md space-y-5">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Role</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="member">Member</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="maxUses"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Max uses</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={1000}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="expiresInDays"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Expires in (days)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{form.formState.errors.root && (
|
||||||
|
<p className="text-destructive text-sm">
|
||||||
|
{form.formState.errors.root.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? "Generating…" : "Generate invite"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user