"use client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { InputGroup, InputGroupAddon, InputGroupInput, } from "@openstatus/ui/components/ui/input-group"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { useDebounce } from "@openstatus/ui/hooks/use-debounce"; import { useMutation, useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import Image from "next/image"; import { useEffect, useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardHeader, FormCardSeparator, FormCardTitle, } from "@/components/forms/form-card"; import { useTRPC } from "@/lib/trpc/client"; const SLUG_UNIQUE_ERROR_MESSAGE = "This slug is already taken. Please choose another one."; // Keep in sync with `slugSchema` in // `packages/db/src/schema/pages/validation.ts`. We can't import that on the // client because `@openstatus/db` is server-only. Slugs are stored lowercase // (subdomains are case-insensitive), so we restrict input client-side too. const SLUG_PATTERN = /^[a-z0-9-]+$/; const SLUG_PATTERN_MESSAGE = "Only use digits (0-9), hyphen (-) or lowercase characters (a-z)."; function formatSlug(title: string) { return title .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } const schema = z.object({ title: z.string().min(1, "Title is required"), slug: z .string() .min(3, "Slug is required") .regex(SLUG_PATTERN, SLUG_PATTERN_MESSAGE), icon: z.string().optional(), description: z.string().optional(), }); type FormValues = z.infer; /** Convert a File to a base64 string without the data: prefix */ async function fileToBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result as string; // result is like "data:image/png;base64,XXXX" – we only need the part after the comma resolve(result.split(",")[1] || ""); }; reader.onerror = reject; reader.readAsDataURL(file); }); } export function FormGeneral({ disabled, defaultValues, onSubmit, ...props }: Omit, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise; disabled?: boolean; }) { const form = useForm({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { title: "", slug: "", icon: undefined, description: "", }, }); const [isPending, startTransition] = useTransition(); const trpc = useTRPC(); const uploadMutation = useMutation(trpc.blob.upload.mutationOptions()); const watchSlug = form.watch("slug"); const watchTitle = form.watch("title"); const watchIcon = form.watch("icon"); const debouncedSlug = useDebounce(watchSlug, 500); const { data: isUnique } = useQuery( trpc.page.getSlugUniqueness.queryOptions( { slug: debouncedSlug }, { enabled: debouncedSlug.length > 0 }, ), ); useEffect(() => { if (!defaultValues?.title) { const formattedSlug = formatSlug(watchTitle); form.setValue("slug", formattedSlug); } }, [form, defaultValues?.title, watchTitle]); useEffect(() => { if (isUnique === undefined) return; if (defaultValues?.slug === debouncedSlug) return; if (!isUnique) { form.setError("slug", { message: SLUG_UNIQUE_ERROR_MESSAGE }); } else { form.clearErrors("slug"); } }, [isUnique, form, debouncedSlug, defaultValues?.slug]); function submitAction(values: FormValues) { if (isPending || disabled) return; startTransition(async () => { try { if (isUnique === false && defaultValues?.slug !== values.slug) { toast.error(SLUG_UNIQUE_ERROR_MESSAGE); form.setError("slug", { message: SLUG_UNIQUE_ERROR_MESSAGE }); return; } const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return (
General Configure the essential details for your status page. ( Title Enter a descriptive name for your status page. )} /> ( Slug .openstatus.dev Choose a unique subdomain for your status page (minimum 3 characters). )} /> ( Icon
{watchIcon ? ( <>
Icon preview
) : ( { const file = e.target.files?.[0]; if (!file) return; const base64String = await fileToBase64(file); try { const blob = await uploadMutation.mutateAsync({ filename: file.name, file: base64String, }); if (blob?.url) { form.setValue("icon", blob.url as string); } } catch (err) { console.error(err); toast.error("Upload failed"); } }} /> )}
Select an icon for your status page. PNG/ICO: 512x512px recommended. SVG also supported. Will be used as favicon.
)} /> ( Description