diff --git a/apps/web/src/app/(authenticated)/settings/domains/page.tsx b/apps/web/src/app/(authenticated)/settings/domains/page.tsx index a945f87..20ca4a7 100644 --- a/apps/web/src/app/(authenticated)/settings/domains/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/domains/page.tsx @@ -1,23 +1,383 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, type SyntheticEvent } from "react"; +import type { ReactElement } from "react"; import type { Domain } from "@mosaic/shared"; import { DomainList } from "@/components/domains/DomainList"; -import { fetchDomains, deleteDomain } from "@/lib/api/domains"; +import { fetchDomains, createDomain, deleteDomain } from "@/lib/api/domains"; +import type { CreateDomainDto } from "@/lib/api/domains"; +import { useWorkspaceId } from "@/lib/hooks"; -export default function DomainsPage(): React.ReactElement { +/* --------------------------------------------------------------------------- + Slug generation helper + --------------------------------------------------------------------------- */ + +function generateSlug(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .slice(0, 100); +} + +/* --------------------------------------------------------------------------- + Create Domain Dialog + --------------------------------------------------------------------------- */ + +interface CreateDomainDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: CreateDomainDto) => Promise; + isSubmitting: boolean; +} + +function CreateDomainDialog({ + open, + onOpenChange, + onSubmit, + isSubmitting, +}: CreateDomainDialogProps): ReactElement | null { + const [name, setName] = useState(""); + const [slug, setSlug] = useState(""); + const [slugTouched, setSlugTouched] = useState(false); + const [description, setDescription] = useState(""); + const [formError, setFormError] = useState(null); + + function resetForm(): void { + setName(""); + setSlug(""); + setSlugTouched(false); + setDescription(""); + setFormError(null); + } + + function handleNameChange(value: string): void { + setName(value); + if (!slugTouched) { + setSlug(generateSlug(value)); + } + } + + function handleSlugChange(value: string): void { + setSlugTouched(true); + setSlug(value.toLowerCase().replace(/[^a-z0-9-]/g, "")); + } + + async function handleSubmit(e: SyntheticEvent): Promise { + e.preventDefault(); + setFormError(null); + + const trimmedName = name.trim(); + if (!trimmedName) { + setFormError("Domain name is required."); + return; + } + + const trimmedSlug = slug.trim(); + if (!trimmedSlug) { + setFormError("Slug is required."); + return; + } + + if (!/^[a-z0-9-]+$/.test(trimmedSlug)) { + setFormError("Slug must contain only lowercase letters, numbers, and hyphens."); + return; + } + + try { + const payload: CreateDomainDto = { name: trimmedName, slug: trimmedSlug }; + const trimmedDesc = description.trim(); + if (trimmedDesc) { + payload.description = trimmedDesc; + } + await onSubmit(payload); + resetForm(); + } catch (err: unknown) { + setFormError(err instanceof Error ? err.message : "Failed to create domain."); + } + } + + if (!open) return null; + + return ( +
+ {/* Backdrop */} +
{ + if (!isSubmitting) { + resetForm(); + onOpenChange(false); + } + }} + /> + + {/* Dialog */} +
+

+ New Domain +

+

+ Domains help you organize tasks, projects, and events by life area. +

+ +
{ + void handleSubmit(e); + }} + > + {/* Name */} +
+ + { + handleNameChange(e.target.value); + }} + placeholder="e.g. Personal Finance" + maxLength={255} + autoFocus + style={{ + width: "100%", + padding: "8px 12px", + background: "var(--bg, #f9fafb)", + border: "1px solid var(--border, #d1d5db)", + borderRadius: "6px", + color: "var(--text, #111)", + fontSize: "0.9rem", + outline: "none", + boxSizing: "border-box", + }} + /> +
+ + {/* Slug */} +
+ + { + handleSlugChange(e.target.value); + }} + placeholder="e.g. personal-finance" + maxLength={100} + style={{ + width: "100%", + padding: "8px 12px", + background: "var(--bg, #f9fafb)", + border: "1px solid var(--border, #d1d5db)", + borderRadius: "6px", + color: "var(--text, #111)", + fontSize: "0.9rem", + outline: "none", + boxSizing: "border-box", + fontFamily: "var(--mono, monospace)", + }} + /> +

+ Lowercase letters, numbers, and hyphens only. +

+
+ + {/* Description */} +
+ +