@geminilight/mindos 0.5.37 → 0.5.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,31 @@
1
1
  'use client';
2
2
 
3
3
  import Link from 'next/link';
4
- import { FileText, Table, Clock, Sparkles, ArrowRight, FilePlus, Search, ChevronDown, Compass } from 'lucide-react';
5
- import { useState, useEffect } from 'react';
4
+ import { FileText, Table, Clock, Sparkles, ArrowRight, FilePlus, Search, ChevronDown, Compass, Folder, Puzzle, Brain, Plus, Loader2 } from 'lucide-react';
5
+ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
6
6
  import { useLocale } from '@/lib/LocaleContext';
7
7
  import { encodePath, relativeTime } from '@/lib/utils';
8
8
  import { getAllRenderers } from '@/lib/renderers/registry';
9
9
  import '@/lib/renderers/index'; // registers all renderers
10
10
  import OnboardingView from './OnboardingView';
11
11
  import GuideCard from './GuideCard';
12
+ import { useRouter } from 'next/navigation';
13
+ import { createSpaceAction } from '@/lib/actions';
14
+ import type { SpaceInfo } from '@/app/page';
12
15
 
13
16
  interface RecentFile {
14
17
  path: string;
15
18
  mtime: number;
16
19
  }
17
20
 
21
+ interface SpaceGroup {
22
+ space: string;
23
+ spacePath: string;
24
+ files: RecentFile[];
25
+ latestMtime: number;
26
+ totalFiles: number;
27
+ }
28
+
18
29
  function triggerSearch() {
19
30
  window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true }));
20
31
  }
@@ -23,9 +34,70 @@ function triggerAsk() {
23
34
  window.dispatchEvent(new KeyboardEvent('keydown', { key: '/', metaKey: true, bubbles: true }));
24
35
  }
25
36
 
26
- export default function HomeContent({ recent, existingFiles }: { recent: RecentFile[]; existingFiles?: string[] }) {
37
+ /** Group recent files by their top-level directory (Space) */
38
+ function groupBySpace(recent: RecentFile[], spaces: SpaceInfo[]): { groups: SpaceGroup[]; rootFiles: RecentFile[] } {
39
+ const groupMap = new Map<string, SpaceGroup>();
40
+ const rootFiles: RecentFile[] = [];
41
+
42
+ for (const file of recent) {
43
+ const parts = file.path.split('/');
44
+ if (parts.length < 2) {
45
+ rootFiles.push(file);
46
+ continue;
47
+ }
48
+ const spaceName = parts[0];
49
+ const spaceInfo = spaces.find(s => s.name === spaceName);
50
+
51
+ if (!groupMap.has(spaceName)) {
52
+ groupMap.set(spaceName, {
53
+ space: spaceName,
54
+ spacePath: spaceName + '/',
55
+ files: [],
56
+ latestMtime: 0,
57
+ totalFiles: spaceInfo?.fileCount ?? 0,
58
+ });
59
+ }
60
+ const g = groupMap.get(spaceName)!;
61
+ g.files.push(file);
62
+ g.latestMtime = Math.max(g.latestMtime, file.mtime);
63
+ }
64
+
65
+ const groups = [...groupMap.values()].sort((a, b) => b.latestMtime - a.latestMtime);
66
+ return { groups, rootFiles };
67
+ }
68
+
69
+ /** Extract leading emoji from a directory name, e.g. "📝 Notes" → "📝" */
70
+ function extractEmoji(name: string): string {
71
+ const match = name.match(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u);
72
+ return match?.[0] ?? '';
73
+ }
74
+
75
+ /** Strip leading emoji+space from name for display, e.g. "📝 Notes" → "Notes" */
76
+ function stripEmoji(name: string): string {
77
+ return name.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '') || name;
78
+ }
79
+
80
+ /* ── Section Title component (shared across all three sections) ── */
81
+ function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
82
+ return (
83
+ <div className="flex items-center gap-2 mb-4">
84
+ <span className="text-[var(--amber)]">{icon}</span>
85
+ <h2 className="text-xs font-semibold uppercase tracking-[0.08em] font-display text-muted-foreground">
86
+ {children}
87
+ </h2>
88
+ </div>
89
+ );
90
+ }
91
+
92
+ const FILES_PER_GROUP = 3;
93
+ const SPACES_PER_ROW = 6; // 3 cols × 2 rows on desktop, show 1 row initially
94
+ const PLUGINS_INITIAL = 6;
95
+
96
+ export default function HomeContent({ recent, existingFiles, spaces }: { recent: RecentFile[]; existingFiles?: string[]; spaces?: SpaceInfo[] }) {
27
97
  const { t } = useLocale();
28
98
  const [showAll, setShowAll] = useState(false);
99
+ const [showAllSpaces, setShowAllSpaces] = useState(false);
100
+ const [showAllPlugins, setShowAllPlugins] = useState(false);
29
101
  const [suggestionIdx, setSuggestionIdx] = useState(0);
30
102
 
31
103
  const suggestions = t.ask?.suggestions ?? [
@@ -56,10 +128,15 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
56
128
 
57
129
  const lastFile = recent[0];
58
130
 
131
+ // Group recent files by Space
132
+ const spaceList = spaces ?? [];
133
+ const { groups, rootFiles } = useMemo(() => groupBySpace(recent, spaceList), [recent, spaceList]);
134
+
59
135
  return (
60
136
  <div className="content-width px-4 md:px-6 py-8 md:py-12">
61
137
  <GuideCard onNavigate={(path) => { window.location.href = `/view/${encodeURIComponent(path)}`; }} />
62
- {/* Hero */}
138
+
139
+ {/* ── Hero ── */}
63
140
  <div className="mb-10">
64
141
  <div className="flex items-center gap-2 mb-3">
65
142
  <div className="w-1 h-5 rounded-full bg-[var(--amber)]" />
@@ -73,7 +150,6 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
73
150
 
74
151
  {/* AI-first command bar */}
75
152
  <div className="w-full max-w-[620px] flex flex-col sm:flex-row items-stretch sm:items-center gap-2 ml-4">
76
- {/* Ask AI (primary) */}
77
153
  <button
78
154
  onClick={triggerAsk}
79
155
  title="⌘/"
@@ -88,8 +164,6 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
88
164
  ⌘/
89
165
  </kbd>
90
166
  </button>
91
-
92
- {/* Search files (secondary) */}
93
167
  <button
94
168
  onClick={triggerSearch}
95
169
  title="⌘K"
@@ -132,98 +206,253 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
132
206
  <span>{t.explore.title}</span>
133
207
  </Link>
134
208
  </div>
209
+ </div>
135
210
 
136
- {/* Plugin quick-access chips only show available plugins */}
137
- {availablePlugins.length > 0 && (
138
- <div className="flex flex-wrap gap-1.5 mt-3 pl-4">
139
- {availablePlugins.map(r => (
211
+ {/* ── Section 1: Plugins ── */}
212
+ {availablePlugins.length > 0 && (
213
+ <section className="mb-8">
214
+ <SectionTitle icon={<Puzzle size={13} />}>{t.home.plugins}</SectionTitle>
215
+ <div className="flex flex-wrap gap-2">
216
+ {(showAllPlugins ? availablePlugins : availablePlugins.slice(0, PLUGINS_INITIAL)).map(r => (
140
217
  <Link
141
218
  key={r.id}
142
219
  href={`/view/${encodePath(r.entryPath!)}`}
143
- className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs text-muted-foreground transition-all duration-100 hover:bg-muted/60"
220
+ className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-xs transition-all duration-150 hover:border-amber-500/30 hover:bg-muted/60"
144
221
  >
145
222
  <span className="text-sm leading-none" suppressHydrationWarning>{r.icon}</span>
146
- <span>{r.name}</span>
223
+ <span className="font-medium text-foreground">{r.name}</span>
147
224
  </Link>
148
225
  ))}
149
226
  </div>
150
- )}
151
- </div>
152
-
153
- {/* Recently modified timeline feed */}
154
- {recent.length > 0 && (() => {
155
- const INITIAL_COUNT = 5;
156
- const visibleRecent = showAll ? recent : recent.slice(0, INITIAL_COUNT);
157
- const hasMore = recent.length > INITIAL_COUNT;
227
+ {availablePlugins.length > PLUGINS_INITIAL && (
228
+ <button
229
+ onClick={() => setShowAllPlugins(v => !v)}
230
+ className="flex items-center gap-1.5 mt-2 text-xs font-medium text-[var(--amber)] transition-colors hover:opacity-80 cursor-pointer font-display"
231
+ >
232
+ <ChevronDown size={12} className={`transition-transform duration-200 ${showAllPlugins ? 'rotate-180' : ''}`} />
233
+ <span>{showAllPlugins ? t.home.showLess : t.home.showMore}</span>
234
+ </button>
235
+ )}
236
+ </section>
237
+ )}
158
238
 
159
- return (
160
- <section className="mb-12">
161
- <div className="flex items-center gap-2 mb-5">
162
- <Clock size={13} className="text-[var(--amber)]" />
163
- <h2 className="text-xs font-semibold uppercase tracking-[0.08em] font-display text-muted-foreground">
164
- {t.home.recentlyModified}
165
- </h2>
239
+ {/* ── Section 2: Spaces ── */}
240
+ {spaceList.length > 0 ? (
241
+ <section className="mb-8">
242
+ <SectionTitle icon={<Brain size={13} />}>{t.home.spaces}</SectionTitle>
243
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
244
+ {(showAllSpaces ? spaceList : spaceList.slice(0, SPACES_PER_ROW)).map(s => {
245
+ const emoji = extractEmoji(s.name);
246
+ const label = stripEmoji(s.name);
247
+ const isEmpty = s.fileCount === 0;
248
+ return (
249
+ <Link
250
+ key={s.name}
251
+ href={`/view/${encodePath(s.path)}`}
252
+ className={`flex items-start gap-3 px-3.5 py-3 rounded-xl border transition-all duration-150 hover:translate-x-0.5 ${
253
+ isEmpty
254
+ ? 'border-dashed border-border/50 opacity-50 hover:opacity-70'
255
+ : 'border-border hover:border-amber-500/30 hover:bg-muted/40'
256
+ }`}
257
+ >
258
+ {emoji ? (
259
+ <span className="text-lg leading-none shrink-0 mt-0.5" suppressHydrationWarning>{emoji}</span>
260
+ ) : (
261
+ <Folder size={16} className="shrink-0 text-[var(--amber)] mt-0.5" />
262
+ )}
263
+ <div className="min-w-0 flex-1">
264
+ <span className="text-sm font-medium truncate block text-foreground">{label}</span>
265
+ {s.description && (
266
+ <span className="text-xs text-muted-foreground line-clamp-1 mt-0.5" suppressHydrationWarning>{s.description}</span>
267
+ )}
268
+ <span className="text-xs text-muted-foreground opacity-50 mt-0.5 block">
269
+ {t.home.nFiles(s.fileCount)}
270
+ </span>
271
+ </div>
272
+ </Link>
273
+ );
274
+ })}
275
+ {/* Show "+" card inline only when it won't be orphaned on its own row */}
276
+ {(showAllSpaces || spaceList.length < SPACES_PER_ROW) && <CreateSpaceCard t={t} />}
277
+ </div>
278
+ {spaceList.length > SPACES_PER_ROW && (
279
+ <div className="flex items-center gap-3 mt-2">
280
+ <button
281
+ onClick={() => setShowAllSpaces(v => !v)}
282
+ className="flex items-center gap-1.5 text-xs font-medium text-[var(--amber)] transition-colors hover:opacity-80 cursor-pointer font-display"
283
+ >
284
+ <ChevronDown size={12} className={`transition-transform duration-200 ${showAllSpaces ? 'rotate-180' : ''}`} />
285
+ <span>{showAllSpaces ? t.home.showLess : t.home.showMore}</span>
286
+ </button>
287
+ {/* When collapsed and "+" card is hidden from grid, show as text link */}
288
+ {!showAllSpaces && <CreateSpaceTextLink t={t} />}
289
+ </div>
290
+ )}
291
+ </section>
292
+ ) : (
293
+ /* Show create card even when no spaces exist */
294
+ <section className="mb-8">
295
+ <SectionTitle icon={<Brain size={13} />}>{t.home.spaces}</SectionTitle>
296
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
297
+ <CreateSpaceCard t={t} />
166
298
  </div>
299
+ </section>
300
+ )}
167
301
 
168
- <div className="relative pl-4">
169
- {/* Timeline line */}
170
- <div className="absolute left-0 top-1 bottom-1 w-px bg-border" />
302
+ {/* ── Section 3: Recently Edited ── */}
303
+ {recent.length > 0 && (
304
+ <section className="mb-12">
305
+ <SectionTitle icon={<Clock size={13} />}>{t.home.recentlyEdited}</SectionTitle>
171
306
 
172
- <div className="flex flex-col gap-0.5">
173
- {visibleRecent.map(({ path: filePath, mtime }, idx) => {
174
- const isCSV = filePath.endsWith('.csv');
175
- const name = filePath.split('/').pop() || filePath;
176
- const dir = filePath.split('/').slice(0, -1).join('/');
307
+ {groups.length > 0 ? (
308
+ /* Space-Grouped View */
309
+ <div className="flex flex-col gap-4">
310
+ {groups.map((group) => {
311
+ const visibleFiles = showAll ? group.files : group.files.slice(0, FILES_PER_GROUP);
312
+ const hasMoreFiles = group.files.length > FILES_PER_GROUP;
177
313
  return (
178
- <div key={filePath} className="relative group">
179
- {/* Timeline dot */}
180
- <div
181
- aria-hidden="true"
182
- className={`absolute -left-4 top-1/2 -translate-y-1/2 rounded-full transition-all duration-150 group-hover:scale-150 ${idx === 0 ? 'w-2 h-2' : 'w-1.5 h-1.5'}`}
183
- style={{
184
- background: idx === 0 ? 'var(--amber)' : 'var(--border)',
185
- outline: idx === 0 ? '2px solid var(--amber-dim)' : 'none',
186
- }}
187
- />
314
+ <div key={group.space}>
188
315
  <Link
189
- href={`/view/${encodePath(filePath)}`}
190
- className="flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-100 group-hover:translate-x-0.5 hover:bg-muted"
316
+ href={`/view/${encodePath(group.spacePath)}`}
317
+ className="flex items-center gap-2 px-1 py-1.5 rounded-lg group transition-colors hover:bg-muted/50"
191
318
  >
192
- {isCSV
193
- ? <Table size={13} className="shrink-0 text-success" />
194
- : <FileText size={13} className="shrink-0 text-muted-foreground" />
195
- }
196
- <div className="flex-1 min-w-0">
197
- <span className="text-sm font-medium truncate block text-foreground" suppressHydrationWarning>{name}</span>
198
- {dir && <span className="text-xs truncate block text-muted-foreground opacity-60" suppressHydrationWarning>{dir}</span>}
199
- </div>
200
- <span className="text-xs shrink-0 tabular-nums font-display text-muted-foreground opacity-50" suppressHydrationWarning>
201
- {formatTime(mtime)}
319
+ <Folder size={13} className="shrink-0 text-[var(--amber)]" />
320
+ <span className="text-xs font-semibold font-display text-foreground group-hover:text-[var(--amber)] transition-colors" suppressHydrationWarning>
321
+ {group.space}
202
322
  </span>
323
+ <span className="text-xs text-muted-foreground opacity-60 tabular-nums" suppressHydrationWarning>
324
+ {t.home.nFiles(group.totalFiles)} · {formatTime(group.latestMtime)}
325
+ </span>
326
+ {hasMoreFiles && !showAll && (
327
+ <span className="text-xs text-muted-foreground opacity-40">
328
+ +{group.files.length - FILES_PER_GROUP}
329
+ </span>
330
+ )}
203
331
  </Link>
332
+ <div className="flex flex-col gap-0.5 ml-2 border-l border-border pl-3">
333
+ {visibleFiles.map(({ path: filePath, mtime }) => {
334
+ const isCSV = filePath.endsWith('.csv');
335
+ const name = filePath.split('/').pop() || filePath;
336
+ const subPath = filePath.split('/').slice(1, -1).join('/');
337
+ return (
338
+ <Link
339
+ key={filePath}
340
+ href={`/view/${encodePath(filePath)}`}
341
+ className="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-100 hover:translate-x-0.5 hover:bg-muted group"
342
+ >
343
+ {isCSV
344
+ ? <Table size={12} className="shrink-0 text-success" />
345
+ : <FileText size={12} className="shrink-0 text-muted-foreground" />
346
+ }
347
+ <div className="flex-1 min-w-0">
348
+ <span className="text-sm truncate block text-foreground" suppressHydrationWarning>{name}</span>
349
+ {subPath && <span className="text-xs truncate block text-muted-foreground opacity-50" suppressHydrationWarning>{subPath}</span>}
350
+ </div>
351
+ <span className="text-xs shrink-0 tabular-nums font-display text-muted-foreground opacity-40" suppressHydrationWarning>
352
+ {formatTime(mtime)}
353
+ </span>
354
+ </Link>
355
+ );
356
+ })}
357
+ </div>
204
358
  </div>
205
359
  );
206
360
  })}
207
- </div>
208
361
 
209
- {/* Show more / less */}
210
- {hasMore && (
211
- <button
212
- onClick={() => setShowAll(v => !v)}
213
- aria-expanded={showAll}
214
- className="flex items-center gap-1.5 mt-2 ml-3 text-xs font-medium text-[var(--amber)] transition-colors hover:opacity-80 cursor-pointer font-display"
215
- >
216
- <ChevronDown
217
- size={12}
218
- className={`transition-transform duration-200 ${showAll ? 'rotate-180' : ''}`}
219
- />
220
- <span>{showAll ? t.home.showLess : t.home.showMore}</span>
221
- </button>
222
- )}
223
- </div>
362
+ {/* Root-level files (Other) */}
363
+ {rootFiles.length > 0 && (
364
+ <div>
365
+ <div className="flex items-center gap-2 px-1 py-1.5">
366
+ <FileText size={13} className="shrink-0 text-muted-foreground" />
367
+ <span className="text-xs font-semibold font-display text-muted-foreground">
368
+ {t.home.other}
369
+ </span>
370
+ </div>
371
+ <div className="flex flex-col gap-0.5 ml-2 border-l border-border pl-3">
372
+ {rootFiles.map(({ path: filePath, mtime }) => (
373
+ <Link
374
+ key={filePath}
375
+ href={`/view/${encodePath(filePath)}`}
376
+ className="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-100 hover:translate-x-0.5 hover:bg-muted"
377
+ >
378
+ <FileText size={12} className="shrink-0 text-muted-foreground" />
379
+ <span className="text-sm flex-1 min-w-0 truncate text-foreground" suppressHydrationWarning>
380
+ {filePath.split('/').pop() || filePath}
381
+ </span>
382
+ <span className="text-xs shrink-0 tabular-nums font-display text-muted-foreground opacity-40" suppressHydrationWarning>
383
+ {formatTime(mtime)}
384
+ </span>
385
+ </Link>
386
+ ))}
387
+ </div>
388
+ </div>
389
+ )}
390
+
391
+ {/* Show more / less */}
392
+ {groups.some(g => g.files.length > FILES_PER_GROUP) && (
393
+ <button
394
+ onClick={() => setShowAll(v => !v)}
395
+ aria-expanded={showAll}
396
+ className="flex items-center gap-1.5 mt-1 ml-1 text-xs font-medium text-[var(--amber)] transition-colors hover:opacity-80 cursor-pointer font-display"
397
+ >
398
+ <ChevronDown size={12} className={`transition-transform duration-200 ${showAll ? 'rotate-180' : ''}`} />
399
+ <span>{showAll ? t.home.showLess : t.home.showMore}</span>
400
+ </button>
401
+ )}
402
+ </div>
403
+ ) : (
404
+ /* Flat Timeline Fallback */
405
+ <div className="relative pl-4">
406
+ <div className="absolute left-0 top-1 bottom-1 w-px bg-border" />
407
+ <div className="flex flex-col gap-0.5">
408
+ {(showAll ? recent : recent.slice(0, 5)).map(({ path: filePath, mtime }, idx) => {
409
+ const isCSV = filePath.endsWith('.csv');
410
+ const name = filePath.split('/').pop() || filePath;
411
+ const dir = filePath.split('/').slice(0, -1).join('/');
412
+ return (
413
+ <div key={filePath} className="relative group">
414
+ <div
415
+ aria-hidden="true"
416
+ className={`absolute -left-4 top-1/2 -translate-y-1/2 rounded-full transition-all duration-150 group-hover:scale-150 ${idx === 0 ? 'w-2 h-2' : 'w-1.5 h-1.5'}`}
417
+ style={{
418
+ background: idx === 0 ? 'var(--amber)' : 'var(--border)',
419
+ outline: idx === 0 ? '2px solid var(--amber-dim)' : 'none',
420
+ }}
421
+ />
422
+ <Link
423
+ href={`/view/${encodePath(filePath)}`}
424
+ className="flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-100 group-hover:translate-x-0.5 hover:bg-muted"
425
+ >
426
+ {isCSV
427
+ ? <Table size={13} className="shrink-0 text-success" />
428
+ : <FileText size={13} className="shrink-0 text-muted-foreground" />
429
+ }
430
+ <div className="flex-1 min-w-0">
431
+ <span className="text-sm font-medium truncate block text-foreground" suppressHydrationWarning>{name}</span>
432
+ {dir && <span className="text-xs truncate block text-muted-foreground opacity-60" suppressHydrationWarning>{dir}</span>}
433
+ </div>
434
+ <span className="text-xs shrink-0 tabular-nums font-display text-muted-foreground opacity-50" suppressHydrationWarning>
435
+ {formatTime(mtime)}
436
+ </span>
437
+ </Link>
438
+ </div>
439
+ );
440
+ })}
441
+ </div>
442
+ {recent.length > 5 && (
443
+ <button
444
+ onClick={() => setShowAll(v => !v)}
445
+ aria-expanded={showAll}
446
+ className="flex items-center gap-1.5 mt-2 ml-3 text-xs font-medium text-[var(--amber)] transition-colors hover:opacity-80 cursor-pointer font-display"
447
+ >
448
+ <ChevronDown size={12} className={`transition-transform duration-200 ${showAll ? 'rotate-180' : ''}`} />
449
+ <span>{showAll ? t.home.showLess : t.home.showMore}</span>
450
+ </button>
451
+ )}
452
+ </div>
453
+ )}
224
454
  </section>
225
- );
226
- })()}
455
+ )}
227
456
 
228
457
  {/* Footer */}
229
458
  <div className="mt-16 flex items-center gap-1.5 text-xs font-display text-muted-foreground opacity-60">
@@ -233,3 +462,151 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
233
462
  </div>
234
463
  );
235
464
  }
465
+
466
+ /* ── Create Space inline card ── */
467
+ function CreateSpaceCard({ t }: { t: ReturnType<typeof useLocale>['t'] }) {
468
+ const router = useRouter();
469
+ const [editing, setEditing] = useState(false);
470
+ const [name, setName] = useState('');
471
+ const [description, setDescription] = useState('');
472
+ const [loading, setLoading] = useState(false);
473
+ const [error, setError] = useState('');
474
+ const inputRef = useRef<HTMLInputElement>(null);
475
+
476
+ const open = useCallback(() => {
477
+ setEditing(true);
478
+ setError('');
479
+ setTimeout(() => inputRef.current?.focus(), 50);
480
+ }, []);
481
+
482
+ const close = useCallback(() => {
483
+ setEditing(false);
484
+ setName('');
485
+ setDescription('');
486
+ setError('');
487
+ }, []);
488
+
489
+ const handleCreate = useCallback(async () => {
490
+ if (!name.trim() || loading) return;
491
+ setLoading(true);
492
+ setError('');
493
+ const result = await createSpaceAction(name, description);
494
+ setLoading(false);
495
+ if (result.success) {
496
+ close();
497
+ router.refresh();
498
+ } else {
499
+ setError(result.error ?? 'Failed to create space');
500
+ }
501
+ }, [name, description, loading, close, router]);
502
+
503
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
504
+ if (e.key === 'Escape') close();
505
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleCreate(); }
506
+ }, [close, handleCreate]);
507
+
508
+ if (!editing) {
509
+ return (
510
+ <button
511
+ onClick={open}
512
+ aria-label={t.home.newSpace}
513
+ className="flex items-center justify-center gap-2 px-3.5 py-3 rounded-xl border border-dashed border-border/50 text-muted-foreground opacity-60 transition-all duration-150 hover:opacity-100 hover:border-amber-500/30 cursor-pointer"
514
+ >
515
+ <Plus size={16} />
516
+ <span className="text-sm font-medium">{t.home.newSpace}</span>
517
+ </button>
518
+ );
519
+ }
520
+
521
+ return (
522
+ <div className="flex flex-col gap-2 px-3.5 py-3 rounded-xl border border-[var(--amber)] bg-[var(--amber-subtle)]" onKeyDown={handleKeyDown}>
523
+ <input
524
+ ref={inputRef}
525
+ type="text"
526
+ value={name}
527
+ onChange={e => { setName(e.target.value); setError(''); }}
528
+ placeholder={t.home.spaceName}
529
+ maxLength={80}
530
+ className="text-sm font-medium bg-transparent border-b border-border pb-1 outline-none placeholder:text-muted-foreground/50 text-foreground focus:border-[var(--amber)]"
531
+ />
532
+ <input
533
+ type="text"
534
+ value={description}
535
+ onChange={e => setDescription(e.target.value)}
536
+ placeholder={t.home.spaceDescription}
537
+ maxLength={200}
538
+ className="text-xs bg-transparent border-b border-border/50 pb-1 outline-none placeholder:text-muted-foreground/40 text-muted-foreground focus:border-[var(--amber)]"
539
+ />
540
+ {error && <span className="text-xs text-error">{error}</span>}
541
+ <div className="flex items-center gap-2 mt-1">
542
+ <button
543
+ onClick={handleCreate}
544
+ disabled={!name.trim() || loading}
545
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--amber)] text-white transition-colors hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
546
+ >
547
+ {loading && <Loader2 size={12} className="animate-spin" />}
548
+ {t.home.createSpace}
549
+ </button>
550
+ <button
551
+ onClick={close}
552
+ className="px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground transition-colors hover:bg-muted"
553
+ >
554
+ {t.home.cancelCreate}
555
+ </button>
556
+ </div>
557
+ </div>
558
+ );
559
+ }
560
+
561
+ /** Compact "+ New Space" text link shown next to "Show more" when grid is collapsed */
562
+ function CreateSpaceTextLink({ t }: { t: ReturnType<typeof useLocale>['t'] }) {
563
+ const router = useRouter();
564
+ const [editing, setEditing] = useState(false);
565
+ const [name, setName] = useState('');
566
+ const [loading, setLoading] = useState(false);
567
+ const inputRef = useRef<HTMLInputElement>(null);
568
+
569
+ if (!editing) {
570
+ return (
571
+ <button
572
+ onClick={() => { setEditing(true); setTimeout(() => inputRef.current?.focus(), 50); }}
573
+ className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-[var(--amber)] cursor-pointer"
574
+ >
575
+ <Plus size={11} />
576
+ <span>{t.home.newSpace}</span>
577
+ </button>
578
+ );
579
+ }
580
+
581
+ return (
582
+ <form
583
+ className="flex items-center gap-2"
584
+ onSubmit={async (e) => {
585
+ e.preventDefault();
586
+ if (!name.trim() || loading) return;
587
+ setLoading(true);
588
+ const result = await createSpaceAction(name, '');
589
+ setLoading(false);
590
+ if (result.success) { setEditing(false); setName(''); router.refresh(); }
591
+ }}
592
+ >
593
+ <input
594
+ ref={inputRef}
595
+ type="text"
596
+ value={name}
597
+ onChange={e => setName(e.target.value)}
598
+ onKeyDown={e => { if (e.key === 'Escape') { setEditing(false); setName(''); } }}
599
+ placeholder={t.home.spaceName}
600
+ maxLength={80}
601
+ className="text-xs bg-transparent border-b border-border pb-0.5 outline-none w-28 placeholder:text-muted-foreground/40 text-foreground focus:border-[var(--amber)]"
602
+ />
603
+ <button
604
+ type="submit"
605
+ disabled={!name.trim() || loading}
606
+ className="text-xs font-medium text-[var(--amber)] disabled:opacity-40"
607
+ >
608
+ {loading ? '...' : t.home.createSpace}
609
+ </button>
610
+ </form>
611
+ );
612
+ }