diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1bfa5f1f..a0b7410b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -21,6 +21,7 @@ import type { User, ManagedNamespace, Namespace, + CreateNamespaceRequest, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -467,6 +468,20 @@ function normalizeNamespaceSlug(namespace: string): string { } export const namespaceApi = { + async create(request: CreateNamespaceRequest): Promise { + const namespace = await unwrap(client.POST('/api/v1/namespaces', { + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: { + slug: normalizeNamespaceSlug(request.slug), + displayName: request.displayName.trim(), + description: request.description?.trim() || undefined, + }, + } as never) as never) + return namespace + }, + async listMine(): Promise { return fetchJson(`${WEB_API_PREFIX}/me/namespaces`) }, diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 36a70f77..e2667969 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -61,6 +61,12 @@ export interface ChangePasswordRequest { newPassword: string } +export type CreateNamespaceRequest = Omit & { + slug: string + displayName: string + description?: string +} + export interface MergeInitiateRequest { secondaryIdentifier: string } diff --git a/web/src/features/namespace/create-namespace-dialog.tsx b/web/src/features/namespace/create-namespace-dialog.tsx new file mode 100644 index 00000000..733e402b --- /dev/null +++ b/web/src/features/namespace/create-namespace-dialog.tsx @@ -0,0 +1,239 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { CreateNamespaceRequest } from '@/api/types' +import { useCreateNamespace } from '@/shared/hooks/use-skill-queries' +import { toast } from '@/shared/lib/toast' +import { Button } from '@/shared/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/shared/ui/dialog' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { Textarea } from '@/shared/ui/textarea' + +interface CreateNamespaceDialogProps { + children: React.ReactNode +} + +type FieldErrors = { + slug?: string + displayName?: string + description?: string +} + +const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ +const RESERVED_SLUGS = new Set([ + 'admin', + 'api', + 'dashboard', + 'search', + 'auth', + 'me', + 'global', + 'system', + 'static', + 'assets', + 'health', +]) +const MAX_SLUG_LENGTH = 64 +const MIN_SLUG_LENGTH = 2 +const MAX_DISPLAY_NAME_LENGTH = 128 +const MAX_DESCRIPTION_LENGTH = 512 + +function buildFieldErrors(request: CreateNamespaceRequest, t: (key: string, options?: Record) => string): FieldErrors { + const errors: FieldErrors = {} + const slug = request.slug.trim() + const displayName = request.displayName.trim() + const description = request.description?.trim() ?? '' + + if (!slug) { + errors.slug = t('myNamespaces.createSlugRequired') + } else if (slug.length < MIN_SLUG_LENGTH || slug.length > MAX_SLUG_LENGTH) { + errors.slug = t('myNamespaces.createSlugLength', { min: MIN_SLUG_LENGTH, max: MAX_SLUG_LENGTH }) + } else if (!SLUG_PATTERN.test(slug)) { + errors.slug = t('myNamespaces.createSlugPattern') + } else if (slug.includes('--')) { + errors.slug = t('myNamespaces.createSlugDoubleHyphen') + } else if (RESERVED_SLUGS.has(slug)) { + errors.slug = t('myNamespaces.createSlugReserved', { slug }) + } + + if (!displayName) { + errors.displayName = t('myNamespaces.createDisplayNameRequired') + } else if (displayName.length > MAX_DISPLAY_NAME_LENGTH) { + errors.displayName = t('myNamespaces.createDisplayNameLength', { max: MAX_DISPLAY_NAME_LENGTH }) + } + + if (description.length > MAX_DESCRIPTION_LENGTH) { + errors.description = t('myNamespaces.createDescriptionLength', { max: MAX_DESCRIPTION_LENGTH }) + } + + return errors +} + +export function CreateNamespaceDialog({ children }: CreateNamespaceDialogProps) { + const { t } = useTranslation() + const createMutation = useCreateNamespace() + const [open, setOpen] = useState(false) + const [slug, setSlug] = useState('') + const [displayName, setDisplayName] = useState('') + const [description, setDescription] = useState('') + const [errors, setErrors] = useState({}) + + const resetDialog = () => { + setSlug('') + setDisplayName('') + setDescription('') + setErrors({}) + createMutation.reset() + } + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) { + resetDialog() + } + } + + const normalizedRequest: CreateNamespaceRequest = { + slug: slug.trim().toLowerCase(), + displayName: displayName.trim(), + description: description.trim() || undefined, + } + + const handleSubmit = async () => { + const nextErrors = buildFieldErrors(normalizedRequest, t) + if (Object.keys(nextErrors).length > 0) { + setErrors(nextErrors) + return + } + + try { + const namespace = await createMutation.mutateAsync(normalizedRequest) + toast.success( + t('myNamespaces.createSuccessTitle'), + t('myNamespaces.createSuccessDescription', { name: namespace.displayName }), + ) + handleOpenChange(false) + } catch (error) { + toast.error(t('myNamespaces.createErrorTitle'), error instanceof Error ? error.message : '') + } + } + + const slugLength = slug.trim().length + const displayNameLength = displayName.trim().length + const descriptionLength = description.trim().length + + return ( + + {children} + + + {t('myNamespaces.createDialogTitle')} + + {t('myNamespaces.createDialogDescription')} + + + +
+
+ + { + setSlug(event.target.value.toLowerCase()) + if (errors.slug) { + setErrors((current) => ({ ...current, slug: undefined })) + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + handleSubmit() + } + }} + aria-invalid={errors.slug ? 'true' : 'false'} + /> +
+ {errors.slug ?? t('myNamespaces.createSlugHint')} + {slugLength}/{MAX_SLUG_LENGTH} +
+
+ +
+ + { + setDisplayName(event.target.value) + if (errors.displayName) { + setErrors((current) => ({ ...current, displayName: undefined })) + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + handleSubmit() + } + }} + aria-invalid={errors.displayName ? 'true' : 'false'} + /> +
+ {errors.displayName ?? ''} + {displayNameLength}/{MAX_DISPLAY_NAME_LENGTH} +
+
+ +
+ +