Skip to content

Commit f44f8af

Browse files
committed
feat(ui): shared Create-Space modal with icon picker + live preview
Space creation was inconsistent: the Spaces page had a basic 8-emoji modal, while the sidebar SpaceSwitcher only took a name and hardcoded the 🌐 icon. Both now open one shared CreateSpaceModal: - live preview tile (chosen icon + name) so you see the result before creating - curated 24-emoji picker grid plus a "paste any emoji" field - name input with Enter-to-create, Escape/overlay-click to close - owns the create call + store update + success/error toasts; parents just decide when to open it (SpaceSwitcher also switches to the new Space on create) Preserves the existing create-space-* test ids so the e2e specs keep working. Adds a vitest test (icon selection updates preview/custom field, create passes the chosen name+icon, fires onCreated/onClose). Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 8ccaa80 commit f44f8af

5 files changed

Lines changed: 304 additions & 205 deletions

File tree

apps/desktop/src/components/SpaceSwitcher.tsx

Lines changed: 21 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { useState, useRef, useEffect } from 'react';
22
import { ChevronDown, Check, Plus, Loader2 } from 'lucide-react';
3-
import { Button, useToast, ToastContainer } from '@mcpmux/ui';
43
import { useAppStore, useViewSpace, useSpaces, useIsLoading } from '@/stores';
5-
import { createSpace } from '@/lib/api/spaces';
64
import { spaceAccentTint } from '@/lib/spaceAccent';
5+
import { CreateSpaceModal } from '@/features/spaces/CreateSpaceModal';
76

87
/** Space icon inside a soft tile tinted with the Space's accent color. */
98
function SpaceGlyph({
@@ -42,23 +41,18 @@ interface SpaceSwitcherProps {
4241
*/
4342
export function SpaceSwitcher({ className = '' }: SpaceSwitcherProps) {
4443
const [isOpen, setIsOpen] = useState(false);
45-
const [isCreating, setIsCreating] = useState(false);
46-
const [newName, setNewName] = useState('');
47-
const [showCreateInput, setShowCreateInput] = useState(false);
44+
const [showCreateModal, setShowCreateModal] = useState(false);
4845
const dropdownRef = useRef<HTMLDivElement>(null);
49-
const { toasts, success, error: showError, dismiss } = useToast();
5046

5147
const spaces = useSpaces();
5248
const viewSpace = useViewSpace();
5349
const isLoadingSpaces = useIsLoading('spaces');
5450
const setViewSpaceInStore = useAppStore((state) => state.setViewSpace);
55-
const addSpace = useAppStore((state) => state.addSpace);
5651

5752
useEffect(() => {
5853
function handleClickOutside(event: MouseEvent) {
5954
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
6055
setIsOpen(false);
61-
setShowCreateInput(false);
6256
}
6357
}
6458
document.addEventListener('mousedown', handleClickOutside);
@@ -70,27 +64,8 @@ export function SpaceSwitcher({ className = '' }: SpaceSwitcherProps) {
7064
setIsOpen(false);
7165
};
7266

73-
const handleCreateSpace = async () => {
74-
if (!newName.trim()) return;
75-
setIsCreating(true);
76-
try {
77-
const space = await createSpace(newName.trim(), '🌐');
78-
addSpace(space);
79-
setViewSpaceInStore(space.id);
80-
setNewName('');
81-
setShowCreateInput(false);
82-
setIsOpen(false);
83-
success('Space created', `"${space.name}" has been created`);
84-
} catch (e) {
85-
showError('Failed to create space', e instanceof Error ? e.message : String(e));
86-
} finally {
87-
setIsCreating(false);
88-
}
89-
};
90-
9167
return (
9268
<div ref={dropdownRef} className={`relative ${className}`}>
93-
<ToastContainer toasts={toasts} onClose={dismiss} />
9469
{/* Trigger Button */}
9570
<button
9671
onClick={() => setIsOpen(!isOpen)}
@@ -169,46 +144,29 @@ export function SpaceSwitcher({ className = '' }: SpaceSwitcherProps) {
169144
{/* Divider */}
170145
<div className="mx-1.5 border-t border-[rgb(var(--border))]" />
171146

172-
{/* Create New */}
147+
{/* Create New — opens the shared modal (name + icon picker) */}
173148
<div className="p-1.5">
174-
{showCreateInput ? (
175-
<div className="flex gap-2 p-1">
176-
<input
177-
type="text"
178-
value={newName}
179-
onChange={(e) => setNewName(e.target.value)}
180-
placeholder="Space name..."
181-
autoFocus
182-
className="input flex-1 py-1.5"
183-
onKeyDown={(e) => {
184-
if (e.key === 'Enter') handleCreateSpace();
185-
if (e.key === 'Escape') {
186-
setShowCreateInput(false);
187-
setNewName('');
188-
}
189-
}}
190-
/>
191-
<Button
192-
size="sm"
193-
variant="primary"
194-
onClick={handleCreateSpace}
195-
disabled={isCreating || !newName.trim()}
196-
>
197-
{isCreating ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Add'}
198-
</Button>
199-
</div>
200-
) : (
201-
<button
202-
onClick={() => setShowCreateInput(true)}
203-
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-[rgb(var(--muted))] transition-all duration-150 hover:bg-[rgb(var(--surface-hover))] hover:text-[rgb(var(--foreground))]"
204-
>
205-
<Plus className="h-4 w-4" />
206-
Create new space
207-
</button>
208-
)}
149+
<button
150+
onClick={() => {
151+
setShowCreateModal(true);
152+
setIsOpen(false);
153+
}}
154+
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-[rgb(var(--muted))] transition-all duration-150 hover:bg-[rgb(var(--surface-hover))] hover:text-[rgb(var(--foreground))]"
155+
data-testid="space-switcher-create"
156+
>
157+
<Plus className="h-4 w-4" />
158+
Create new space
159+
</button>
209160
</div>
210161
</div>
211162
)}
163+
164+
{/* New space: name + icon picker. On success, switch to the new Space. */}
165+
<CreateSpaceModal
166+
open={showCreateModal}
167+
onClose={() => setShowCreateModal(false)}
168+
onCreated={(space) => setViewSpaceInStore(space.id)}
169+
/>
212170
</div>
213171
);
214172
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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

Comments
 (0)