|
| 1 | +import { useState, useEffect, useRef } from 'react'; |
| 2 | +import { Plus, Loader2, X } from 'lucide-react'; |
| 3 | +import { |
| 4 | + Button, |
| 5 | + Card, |
| 6 | + CardHeader, |
| 7 | + CardTitle, |
| 8 | + CardContent, |
| 9 | + useToast, |
| 10 | + ToastContainer, |
| 11 | +} from '@mcpmux/ui'; |
| 12 | +import { useAppStore } from '@/stores'; |
| 13 | +import { createSpace, type Space } from '@/lib/api/spaces'; |
| 14 | + |
| 15 | +const DEFAULT_ICON = '🌐'; |
| 16 | + |
| 17 | +/** Curated icon set — three rows of eight in the picker grid. */ |
| 18 | +const ICON_CHOICES = [ |
| 19 | + '🌐', '💼', '🏢', '🏠', '🚀', '🎯', '📦', '🗂️', |
| 20 | + '💻', '🐳', '☁️', '🗄️', '🔌', '⚙️', '🤖', '🧪', |
| 21 | + '🔒', '🔥', '⭐', '✨', '🎨', '📚', '🌱', '☕', |
| 22 | +]; |
| 23 | + |
| 24 | +interface CreateSpaceModalProps { |
| 25 | + open: boolean; |
| 26 | + onClose: () => void; |
| 27 | + /** Called after a Space is created (and added to the store) — e.g. to |
| 28 | + * switch the viewed Space. The Space is already persisted + in the store. */ |
| 29 | + onCreated?: (space: Space) => void; |
| 30 | +} |
| 31 | + |
| 32 | +/** |
| 33 | + * Shared "Create Space" dialog used by both the Spaces page and the sidebar |
| 34 | + * SpaceSwitcher, so naming + icon selection feel identical everywhere. |
| 35 | + * Owns the create call, store update, and success/error toasts; the parent |
| 36 | + * only decides when to open it and what to do with the new Space. |
| 37 | + */ |
| 38 | +export function CreateSpaceModal({ open, onClose, onCreated }: CreateSpaceModalProps) { |
| 39 | + const addSpace = useAppStore((state) => state.addSpace); |
| 40 | + const { toasts, success, error: showError, dismiss } = useToast(); |
| 41 | + |
| 42 | + const [name, setName] = useState(''); |
| 43 | + const [icon, setIcon] = useState(DEFAULT_ICON); |
| 44 | + const [isCreating, setIsCreating] = useState(false); |
| 45 | + const nameRef = useRef<HTMLInputElement>(null); |
| 46 | + |
| 47 | + // Reset to a clean slate each time the dialog opens, and focus the name. |
| 48 | + useEffect(() => { |
| 49 | + if (!open) return; |
| 50 | + setName(''); |
| 51 | + setIcon(DEFAULT_ICON); |
| 52 | + setIsCreating(false); |
| 53 | + const t = setTimeout(() => nameRef.current?.focus(), 50); |
| 54 | + return () => clearTimeout(t); |
| 55 | + }, [open]); |
| 56 | + |
| 57 | + // Escape closes the dialog. |
| 58 | + useEffect(() => { |
| 59 | + if (!open) return; |
| 60 | + const onKey = (e: KeyboardEvent) => { |
| 61 | + if (e.key === 'Escape') onClose(); |
| 62 | + }; |
| 63 | + document.addEventListener('keydown', onKey); |
| 64 | + return () => document.removeEventListener('keydown', onKey); |
| 65 | + }, [open, onClose]); |
| 66 | + |
| 67 | + const trimmed = name.trim(); |
| 68 | + |
| 69 | + const handleCreate = async () => { |
| 70 | + if (!trimmed || isCreating) return; |
| 71 | + setIsCreating(true); |
| 72 | + try { |
| 73 | + const space = await createSpace(trimmed, icon.trim() || DEFAULT_ICON); |
| 74 | + addSpace(space); |
| 75 | + success('Space created', `"${space.name}" has been created`); |
| 76 | + onCreated?.(space); |
| 77 | + onClose(); |
| 78 | + } catch (e) { |
| 79 | + showError('Failed to create space', e instanceof Error ? e.message : String(e)); |
| 80 | + } finally { |
| 81 | + setIsCreating(false); |
| 82 | + } |
| 83 | + }; |
| 84 | + |
| 85 | + if (!open) return null; |
| 86 | + |
| 87 | + return ( |
| 88 | + <> |
| 89 | + <ToastContainer toasts={toasts} onClose={dismiss} /> |
| 90 | + <div |
| 91 | + className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/50 p-4" |
| 92 | + data-testid="create-space-modal-overlay" |
| 93 | + onMouseDown={(e) => { |
| 94 | + if (e.target === e.currentTarget) onClose(); |
| 95 | + }} |
| 96 | + > |
| 97 | + <Card |
| 98 | + className="animate-in fade-in zoom-in-95 w-full max-w-md shadow-2xl duration-200" |
| 99 | + data-testid="create-space-modal" |
| 100 | + > |
| 101 | + <CardHeader> |
| 102 | + <CardTitle className="flex items-center justify-between"> |
| 103 | + <span className="flex items-center gap-2"> |
| 104 | + <Plus className="h-5 w-5" /> |
| 105 | + Create Space |
| 106 | + </span> |
| 107 | + <button |
| 108 | + onClick={onClose} |
| 109 | + className="rounded p-1 hover:bg-[rgb(var(--surface-hover))]" |
| 110 | + aria-label="Close" |
| 111 | + data-testid="create-space-cancel-x" |
| 112 | + > |
| 113 | + <X className="h-4 w-4" /> |
| 114 | + </button> |
| 115 | + </CardTitle> |
| 116 | + </CardHeader> |
| 117 | + <CardContent className="space-y-4"> |
| 118 | + {/* Live preview */} |
| 119 | + <div className="flex items-center gap-3 rounded-xl border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))] p-3"> |
| 120 | + <span |
| 121 | + className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-[rgb(var(--primary))/12] text-2xl" |
| 122 | + data-testid="create-space-preview-icon" |
| 123 | + > |
| 124 | + {icon || DEFAULT_ICON} |
| 125 | + </span> |
| 126 | + <div className="min-w-0"> |
| 127 | + <div className="truncate text-base font-semibold">{trimmed || 'New Space'}</div> |
| 128 | + <div className="text-xs text-[rgb(var(--muted))]">Preview</div> |
| 129 | + </div> |
| 130 | + </div> |
| 131 | + |
| 132 | + {/* Name */} |
| 133 | + <div> |
| 134 | + <label className="mb-1.5 block text-sm font-medium">Name</label> |
| 135 | + <input |
| 136 | + ref={nameRef} |
| 137 | + type="text" |
| 138 | + value={name} |
| 139 | + onChange={(e) => setName(e.target.value)} |
| 140 | + onKeyDown={(e) => { |
| 141 | + if (e.key === 'Enter') handleCreate(); |
| 142 | + }} |
| 143 | + placeholder="e.g. Personal, Work, Project X" |
| 144 | + className="w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface))] px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-[rgb(var(--primary))]" |
| 145 | + data-testid="create-space-name-input" |
| 146 | + /> |
| 147 | + </div> |
| 148 | + |
| 149 | + {/* Icon picker */} |
| 150 | + <div> |
| 151 | + <label className="mb-1.5 block text-sm font-medium">Icon</label> |
| 152 | + <div className="grid grid-cols-8 gap-1.5" data-testid="create-space-icon-grid"> |
| 153 | + {ICON_CHOICES.map((emoji) => ( |
| 154 | + <button |
| 155 | + key={emoji} |
| 156 | + type="button" |
| 157 | + onClick={() => setIcon(emoji)} |
| 158 | + aria-pressed={icon === emoji} |
| 159 | + className={`flex h-9 w-9 items-center justify-center rounded-lg border text-lg transition-all ${ |
| 160 | + icon === emoji |
| 161 | + ? 'border-[rgb(var(--primary))] bg-[rgb(var(--primary))/12] ring-2 ring-[rgb(var(--primary))/20]' |
| 162 | + : 'border-[rgb(var(--border))] bg-[rgb(var(--surface))] hover:bg-[rgb(var(--surface-hover))]' |
| 163 | + }`} |
| 164 | + data-testid={`create-space-icon-${emoji}`} |
| 165 | + > |
| 166 | + {emoji} |
| 167 | + </button> |
| 168 | + ))} |
| 169 | + </div> |
| 170 | + <div className="mt-2 flex items-center gap-2"> |
| 171 | + <span className="text-xs text-[rgb(var(--muted))]">Or paste any emoji</span> |
| 172 | + <input |
| 173 | + type="text" |
| 174 | + value={icon} |
| 175 | + onChange={(e) => setIcon(e.target.value)} |
| 176 | + maxLength={8} |
| 177 | + aria-label="Custom emoji" |
| 178 | + className="w-14 rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface))] px-2 py-1 text-center text-lg focus:outline-none focus:ring-2 focus:ring-[rgb(var(--primary))]" |
| 179 | + data-testid="create-space-icon-custom" |
| 180 | + /> |
| 181 | + </div> |
| 182 | + </div> |
| 183 | + |
| 184 | + <div className="flex gap-3 pt-1"> |
| 185 | + <Button |
| 186 | + variant="ghost" |
| 187 | + onClick={onClose} |
| 188 | + className="flex-1" |
| 189 | + data-testid="create-space-cancel-btn" |
| 190 | + > |
| 191 | + Cancel |
| 192 | + </Button> |
| 193 | + <Button |
| 194 | + variant="primary" |
| 195 | + onClick={handleCreate} |
| 196 | + disabled={isCreating || !trimmed} |
| 197 | + className="flex-1" |
| 198 | + data-testid="create-space-submit-btn" |
| 199 | + > |
| 200 | + {isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Create Space'} |
| 201 | + </Button> |
| 202 | + </div> |
| 203 | + </CardContent> |
| 204 | + </Card> |
| 205 | + </div> |
| 206 | + </> |
| 207 | + ); |
| 208 | +} |
| 209 | + |
| 210 | +export default CreateSpaceModal; |
0 commit comments