@geminilight/mindos 0.5.40 → 0.5.42
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.
- package/app/app/icon.svg +25 -23
- package/app/app/layout.tsx +10 -4
- package/app/app/page.tsx +20 -1
- package/app/app/view/[...path]/ViewPageClient.tsx +17 -16
- package/app/components/ActivityBar.tsx +29 -3
- package/app/components/CreateSpaceModal.tsx +182 -0
- package/app/components/DirPicker.tsx +129 -0
- package/app/components/HomeContent.tsx +110 -226
- package/app/components/Panel.tsx +1 -0
- package/app/components/SidebarLayout.tsx +4 -0
- package/app/components/ThemeToggle.tsx +1 -1
- package/app/components/UpdateBanner.tsx +22 -30
- package/app/components/panels/DiscoverPanel.tsx +172 -0
- package/app/components/settings/SettingsContent.tsx +23 -2
- package/app/lib/LocaleContext.tsx +12 -2
- package/app/lib/actions.ts +16 -5
- package/app/lib/i18n-en.ts +28 -2
- package/app/lib/i18n-zh.ts +28 -2
- package/app/next-env.d.ts +1 -1
- package/app/package.json +2 -1
- package/bin/cli.js +91 -5
- package/bin/lib/gateway.js +40 -3
- package/bin/lib/skill-check.js +133 -0
- package/bin/lib/startup.js +4 -0
- package/package.json +1 -1
- package/scripts/fix-postcss-deps.cjs +30 -0
- package/skills/mindos/SKILL.md +78 -4
- package/skills/mindos-zh/SKILL.md +78 -4
- package/templates/en//360/237/223/235 Notes/Drafts/README.md" +8 -0
- package/templates/en//360/237/223/235 Notes/README.md" +2 -0
- package/templates/zh//360/237/223/235 /347/254/224/350/256/260/README.md" +2 -0
- package/templates/zh//360/237/223/235 /347/254/224/350/256/260//350/215/211/347/250/277/README.md" +8 -0
package/app/app/icon.svg
CHANGED
|
@@ -1,35 +1,37 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" fill="none">
|
|
2
2
|
<defs>
|
|
3
|
-
<linearGradient id="
|
|
3
|
+
<linearGradient id="grad-human" x1="35" y1="40" x2="5" y2="40" gradientUnits="userSpaceOnUse">
|
|
4
4
|
<stop offset="0%" stop-color="#c8873a" stop-opacity="0.8"/>
|
|
5
5
|
<stop offset="100%" stop-color="#c8873a" stop-opacity="0.3"/>
|
|
6
6
|
</linearGradient>
|
|
7
|
-
<linearGradient id="
|
|
7
|
+
<linearGradient id="grad-agent" x1="35" y1="40" x2="75" y2="40" gradientUnits="userSpaceOnUse">
|
|
8
8
|
<stop offset="0%" stop-color="#c8873a" stop-opacity="0.8"/>
|
|
9
9
|
<stop offset="100%" stop-color="#c8873a" stop-opacity="1"/>
|
|
10
10
|
</linearGradient>
|
|
11
11
|
</defs>
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
<g transform="translate(0, 20)">
|
|
14
|
+
<!-- Human Loop (Left) — dashed -->
|
|
15
|
+
<path
|
|
16
|
+
d="M35,20 C25,35 8,35 8,20 C8,5 25,5 35,20"
|
|
17
|
+
stroke="url(#grad-human)"
|
|
18
|
+
stroke-width="3"
|
|
19
|
+
stroke-dasharray="2 4"
|
|
20
|
+
stroke-linecap="round"
|
|
21
|
+
/>
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
<!-- Agent Loop (Right) — solid -->
|
|
24
|
+
<path
|
|
25
|
+
d="M35,20 C45,2 75,2 75,20 C75,38 45,38 35,20"
|
|
26
|
+
stroke="url(#grad-agent)"
|
|
27
|
+
stroke-width="4.5"
|
|
28
|
+
stroke-linecap="round"
|
|
29
|
+
/>
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
<!-- Spark center -->
|
|
32
|
+
<path
|
|
33
|
+
d="M35,17.5 Q35,20 37.5,20 Q35,20 35,22.5 Q35,20 32.5,20 Q35,20 35,17.5 Z"
|
|
34
|
+
fill="#FEF3C7"
|
|
35
|
+
/>
|
|
36
|
+
</g>
|
|
35
37
|
</svg>
|
package/app/app/layout.tsx
CHANGED
|
@@ -8,6 +8,8 @@ import { LocaleProvider } from '@/lib/LocaleContext';
|
|
|
8
8
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
|
9
9
|
import RegisterSW from './register-sw';
|
|
10
10
|
import UpdateBanner from '@/components/UpdateBanner';
|
|
11
|
+
import { cookies } from 'next/headers';
|
|
12
|
+
import type { Locale } from '@/lib/i18n';
|
|
11
13
|
|
|
12
14
|
const geistSans = Inter({
|
|
13
15
|
variable: '--font-geist-sans',
|
|
@@ -57,7 +59,7 @@ export const viewport = {
|
|
|
57
59
|
viewportFit: 'cover' as const,
|
|
58
60
|
};
|
|
59
61
|
|
|
60
|
-
export default function RootLayout({
|
|
62
|
+
export default async function RootLayout({
|
|
61
63
|
children,
|
|
62
64
|
}: Readonly<{
|
|
63
65
|
children: React.ReactNode;
|
|
@@ -69,8 +71,12 @@ export default function RootLayout({
|
|
|
69
71
|
console.error('[RootLayout] Failed to load file tree:', err);
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
// Read locale from cookie (set by pre-hydration script) so SSR matches client
|
|
75
|
+
const cookieStore = await cookies();
|
|
76
|
+
const ssrLocale: Locale = cookieStore.get('locale')?.value === 'zh' ? 'zh' : 'en';
|
|
77
|
+
|
|
72
78
|
return (
|
|
73
|
-
<html lang=
|
|
79
|
+
<html lang={ssrLocale} suppressHydrationWarning>
|
|
74
80
|
<head>
|
|
75
81
|
<meta name="theme-color" content="#c8871e" />
|
|
76
82
|
{/* Patch Node.removeChild/insertBefore to swallow errors caused by browser
|
|
@@ -84,7 +90,7 @@ export default function RootLayout({
|
|
|
84
90
|
{/* Apply user appearance settings before first paint, preventing flash */}
|
|
85
91
|
<script
|
|
86
92
|
dangerouslySetInnerHTML={{
|
|
87
|
-
__html: `(function(){try{var s=localStorage.getItem('theme');var dark=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.classList.toggle('dark',dark);var cw=localStorage.getItem('content-width');if(cw)document.documentElement.style.setProperty('--content-width-override',cw);var pf=localStorage.getItem('prose-font');var fm={lora:'"Lora", Georgia, serif','ibm-plex-sans':'"IBM Plex Sans", sans-serif',geist:'var(--font-geist-sans), sans-serif','ibm-plex-mono':'"IBM Plex Mono", monospace'};if(pf&&fm[pf])document.documentElement.style.setProperty('--prose-font-override',fm[pf]);}catch(e){}})();`,
|
|
93
|
+
__html: `(function(){try{var s=localStorage.getItem('theme');var dark=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.classList.toggle('dark',dark);var cw=localStorage.getItem('content-width');if(cw)document.documentElement.style.setProperty('--content-width-override',cw);var pf=localStorage.getItem('prose-font');var fm={lora:'"Lora", Georgia, serif','ibm-plex-sans':'"IBM Plex Sans", sans-serif',geist:'var(--font-geist-sans), sans-serif','ibm-plex-mono':'"IBM Plex Mono", monospace'};if(pf&&fm[pf])document.documentElement.style.setProperty('--prose-font-override',fm[pf]);var loc=localStorage.getItem('locale')||'en';document.documentElement.lang=loc==='zh'?'zh':'en';document.cookie='locale='+loc+';path=/;max-age=31536000;SameSite=Lax'}catch(e){}})();`,
|
|
88
94
|
}}
|
|
89
95
|
/>
|
|
90
96
|
</head>
|
|
@@ -92,7 +98,7 @@ export default function RootLayout({
|
|
|
92
98
|
className={`${geistSans.variable} ${geistMono.variable} ${ibmPlexMono.variable} ${ibmPlexSans.variable} ${lora.variable} antialiased bg-background text-foreground`}
|
|
93
99
|
suppressHydrationWarning
|
|
94
100
|
>
|
|
95
|
-
<LocaleProvider>
|
|
101
|
+
<LocaleProvider ssrLocale={ssrLocale}>
|
|
96
102
|
<UpdateBanner />
|
|
97
103
|
<TooltipProvider delay={300}>
|
|
98
104
|
<ErrorBoundary>
|
package/app/app/page.tsx
CHANGED
|
@@ -35,6 +35,21 @@ function extractDescription(spacePath: string): string {
|
|
|
35
35
|
return '';
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Recursively collect all directory paths from the file tree */
|
|
39
|
+
function collectDirPaths(nodes: FileNode[], prefix = ''): string[] {
|
|
40
|
+
const result: string[] = [];
|
|
41
|
+
for (const n of nodes) {
|
|
42
|
+
if (n.type === 'directory' && !n.name.startsWith('.')) {
|
|
43
|
+
const path = prefix ? `${prefix}/${n.name}` : n.name;
|
|
44
|
+
result.push(path);
|
|
45
|
+
if (n.children) {
|
|
46
|
+
result.push(...collectDirPaths(n.children, path));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
function getTopLevelDirs(): SpaceInfo[] {
|
|
39
54
|
try {
|
|
40
55
|
const tree = getFileTree();
|
|
@@ -76,5 +91,9 @@ export default function HomePage() {
|
|
|
76
91
|
|
|
77
92
|
const spaces = getTopLevelDirs();
|
|
78
93
|
|
|
79
|
-
|
|
94
|
+
// Collect all directory paths for hierarchical space creation
|
|
95
|
+
let dirPaths: string[] = [];
|
|
96
|
+
try { dirPaths = collectDirPaths(getFileTree()); } catch { /* ignore */ }
|
|
97
|
+
|
|
98
|
+
return <HomeContent recent={recent} existingFiles={existingFiles} spaces={spaces} dirPaths={dirPaths} />;
|
|
80
99
|
}
|
|
@@ -15,6 +15,8 @@ import TableOfContents from '@/components/TableOfContents';
|
|
|
15
15
|
import FindInPage from '@/components/FindInPage';
|
|
16
16
|
import { resolveRenderer, isRendererEnabled } from '@/lib/renderers/registry';
|
|
17
17
|
import { encodePath } from '@/lib/utils';
|
|
18
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
19
|
+
import DirPicker from '@/components/DirPicker';
|
|
18
20
|
import '@/lib/renderers/index'; // registers all renderers
|
|
19
21
|
|
|
20
22
|
interface ViewPageClientProps {
|
|
@@ -40,6 +42,7 @@ export default function ViewPageClient({
|
|
|
40
42
|
draftDirectories = [],
|
|
41
43
|
createDraftAction,
|
|
42
44
|
}: ViewPageClientProps) {
|
|
45
|
+
const { t } = useLocale();
|
|
43
46
|
const hydrated = useSyncExternalStore(
|
|
44
47
|
() => () => {},
|
|
45
48
|
() => true,
|
|
@@ -304,27 +307,25 @@ export default function ViewPageClient({
|
|
|
304
307
|
{editing ? (
|
|
305
308
|
<div className="content-width xl:mr-[220px]">
|
|
306
309
|
{isDraft && showSaveAs && (
|
|
307
|
-
<div className="mb-3 rounded-lg border border-border bg-card p-3 flex flex-col
|
|
308
|
-
<div
|
|
309
|
-
<label className="text-xs text-muted-foreground">Directory</label>
|
|
310
|
-
<
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
))}
|
|
319
|
-
</select>
|
|
310
|
+
<div className="mb-3 rounded-lg border border-border bg-card p-3 flex flex-col gap-2">
|
|
311
|
+
<div>
|
|
312
|
+
<label className="text-xs text-muted-foreground">{t.view?.saveDirectory ?? 'Directory'}</label>
|
|
313
|
+
<div className="mt-1">
|
|
314
|
+
<DirPicker
|
|
315
|
+
dirPaths={draftDirectories}
|
|
316
|
+
value={saveDir}
|
|
317
|
+
onChange={setSaveDir}
|
|
318
|
+
rootLabel={t.home?.rootLevel ?? 'Root'}
|
|
319
|
+
/>
|
|
320
|
+
</div>
|
|
320
321
|
</div>
|
|
321
|
-
<div
|
|
322
|
-
<label className="text-xs text-muted-foreground">File name</label>
|
|
322
|
+
<div>
|
|
323
|
+
<label className="text-xs text-muted-foreground">{t.view?.saveFileName ?? 'File name'}</label>
|
|
323
324
|
<input
|
|
324
325
|
value={saveName}
|
|
325
326
|
onChange={(e) => setSaveName(e.target.value)}
|
|
326
327
|
onKeyDown={(e) => { if (e.key === 'Enter') handleConfirmDraftSave(); }}
|
|
327
|
-
className="mt-1 w-full px-2 py-1.5 text-sm bg-background border border-border rounded text-foreground"
|
|
328
|
+
className="mt-1 w-full px-2.5 py-1.5 text-sm bg-background border border-border rounded-lg text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
328
329
|
placeholder="Untitled.md"
|
|
329
330
|
/>
|
|
330
331
|
</div>
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useRef, useCallback } from 'react';
|
|
3
|
+
import { useRef, useCallback, useState, useEffect } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
|
-
import { FolderTree, Search, Settings, RefreshCw, Blocks, Bot, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
5
|
+
import { FolderTree, Search, Settings, RefreshCw, Blocks, Bot, Compass, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
6
6
|
import { useLocale } from '@/lib/LocaleContext';
|
|
7
7
|
import { DOT_COLORS, getStatusLevel } from './SyncStatusBar';
|
|
8
8
|
import type { SyncStatus } from './settings/SyncTab';
|
|
9
9
|
import Logo from './Logo';
|
|
10
10
|
|
|
11
|
-
export type PanelId = 'files' | 'search' | 'plugins' | 'agents';
|
|
11
|
+
export type PanelId = 'files' | 'search' | 'plugins' | 'agents' | 'discover';
|
|
12
12
|
|
|
13
13
|
export const RAIL_WIDTH_COLLAPSED = 48;
|
|
14
14
|
export const RAIL_WIDTH_EXPANDED = 180;
|
|
@@ -85,6 +85,28 @@ export default function ActivityBar({
|
|
|
85
85
|
const syncBtnRef = useRef<HTMLButtonElement>(null);
|
|
86
86
|
const { t } = useLocale();
|
|
87
87
|
|
|
88
|
+
// Update available badge — check localStorage for persisted state
|
|
89
|
+
const [hasUpdate, setHasUpdate] = useState(() => {
|
|
90
|
+
if (typeof window === 'undefined') return false;
|
|
91
|
+
const dismissed = localStorage.getItem('mindos_update_dismissed');
|
|
92
|
+
const latest = localStorage.getItem('mindos_update_latest');
|
|
93
|
+
return !!latest && latest !== dismissed;
|
|
94
|
+
});
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const onAvail = (e: Event) => {
|
|
97
|
+
const latest = (e as CustomEvent).detail?.latest;
|
|
98
|
+
if (latest) localStorage.setItem('mindos_update_latest', latest);
|
|
99
|
+
setHasUpdate(true);
|
|
100
|
+
};
|
|
101
|
+
const onDismiss = () => setHasUpdate(false);
|
|
102
|
+
window.addEventListener('mindos:update-available', onAvail);
|
|
103
|
+
window.addEventListener('mindos:update-dismissed', onDismiss);
|
|
104
|
+
return () => {
|
|
105
|
+
window.removeEventListener('mindos:update-available', onAvail);
|
|
106
|
+
window.removeEventListener('mindos:update-dismissed', onDismiss);
|
|
107
|
+
};
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
88
110
|
/** Debounce rapid clicks (300ms) — shared across all Rail buttons */
|
|
89
111
|
const debounced = useCallback((fn: () => void) => {
|
|
90
112
|
const now = Date.now();
|
|
@@ -136,6 +158,7 @@ export default function ActivityBar({
|
|
|
136
158
|
<RailButton icon={<Search size={18} />} label={t.sidebar.searchTitle} shortcut="⌘K" active={activePanel === 'search'} expanded={expanded} onClick={() => toggle('search')} walkthroughId="search-button" />
|
|
137
159
|
<RailButton icon={<Blocks size={18} />} label={t.sidebar.plugins} active={activePanel === 'plugins'} expanded={expanded} onClick={() => toggle('plugins')} />
|
|
138
160
|
<RailButton icon={<Bot size={18} />} label={t.sidebar.agents} active={activePanel === 'agents'} expanded={expanded} onClick={() => toggle('agents')} />
|
|
161
|
+
<RailButton icon={<Compass size={18} />} label={t.sidebar.discover} active={activePanel === 'discover'} expanded={expanded} onClick={() => toggle('discover')} />
|
|
139
162
|
</div>
|
|
140
163
|
|
|
141
164
|
{/* ── Spacer ── */}
|
|
@@ -151,6 +174,9 @@ export default function ActivityBar({
|
|
|
151
174
|
expanded={expanded}
|
|
152
175
|
onClick={() => debounced(onSettingsClick)}
|
|
153
176
|
walkthroughId="settings-button"
|
|
177
|
+
badge={hasUpdate ? (
|
|
178
|
+
<span className={`absolute ${expanded ? 'left-[26px] top-1.5' : 'top-1.5 right-1.5'} w-2 h-2 rounded-full bg-error`} />
|
|
179
|
+
) : undefined}
|
|
154
180
|
/>
|
|
155
181
|
<RailButton
|
|
156
182
|
icon={<RefreshCw size={18} />}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { Folder, Loader2, X } from 'lucide-react';
|
|
6
|
+
import { useRouter } from 'next/navigation';
|
|
7
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
8
|
+
import { encodePath } from '@/lib/utils';
|
|
9
|
+
import { createSpaceAction } from '@/lib/actions';
|
|
10
|
+
import DirPicker from './DirPicker';
|
|
11
|
+
|
|
12
|
+
/* ── Create Space Modal ── */
|
|
13
|
+
|
|
14
|
+
export default function CreateSpaceModal({ t, dirPaths }: { t: ReturnType<typeof useLocale>['t']; dirPaths: string[] }) {
|
|
15
|
+
const router = useRouter();
|
|
16
|
+
const [open, setOpen] = useState(false);
|
|
17
|
+
const [name, setName] = useState('');
|
|
18
|
+
const [description, setDescription] = useState('');
|
|
19
|
+
const [parent, setParent] = useState('');
|
|
20
|
+
const [loading, setLoading] = useState(false);
|
|
21
|
+
const [error, setError] = useState('');
|
|
22
|
+
const [nameHint, setNameHint] = useState('');
|
|
23
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const handler = () => {
|
|
27
|
+
setOpen(true);
|
|
28
|
+
setError('');
|
|
29
|
+
setNameHint('');
|
|
30
|
+
setTimeout(() => inputRef.current?.focus(), 80);
|
|
31
|
+
};
|
|
32
|
+
window.addEventListener('mindos:create-space', handler);
|
|
33
|
+
return () => window.removeEventListener('mindos:create-space', handler);
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
const close = useCallback(() => {
|
|
37
|
+
setOpen(false);
|
|
38
|
+
setName('');
|
|
39
|
+
setDescription('');
|
|
40
|
+
setParent('');
|
|
41
|
+
setError('');
|
|
42
|
+
setNameHint('');
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const validateName = useCallback((val: string) => {
|
|
46
|
+
if (val.includes('/') || val.includes('\\')) {
|
|
47
|
+
setNameHint(t.home.spaceNameNoSlash ?? 'Name cannot contain / or \\');
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
setNameHint('');
|
|
51
|
+
return true;
|
|
52
|
+
}, [t]);
|
|
53
|
+
|
|
54
|
+
const fullPathPreview = useMemo(() => {
|
|
55
|
+
const trimmed = name.trim();
|
|
56
|
+
if (!trimmed) return '';
|
|
57
|
+
return (parent ? parent + '/' : '') + trimmed + '/';
|
|
58
|
+
}, [name, parent]);
|
|
59
|
+
|
|
60
|
+
const handleCreate = useCallback(async () => {
|
|
61
|
+
if (!name.trim() || loading) return;
|
|
62
|
+
if (!validateName(name)) return;
|
|
63
|
+
setLoading(true);
|
|
64
|
+
setError('');
|
|
65
|
+
const trimmed = name.trim();
|
|
66
|
+
const result = await createSpaceAction(trimmed, description, parent);
|
|
67
|
+
setLoading(false);
|
|
68
|
+
if (result.success) {
|
|
69
|
+
const createdPath = result.path ?? trimmed;
|
|
70
|
+
close();
|
|
71
|
+
router.refresh();
|
|
72
|
+
router.push(`/view/${encodePath(createdPath + '/')}`);
|
|
73
|
+
} else {
|
|
74
|
+
const msg = result.error ?? '';
|
|
75
|
+
if (msg.includes('already exists')) {
|
|
76
|
+
setError(t.home.spaceExists ?? 'A space with this name already exists');
|
|
77
|
+
} else {
|
|
78
|
+
setError(t.home.spaceCreateFailed ?? 'Failed to create space');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}, [name, description, parent, loading, close, router, t, validateName]);
|
|
82
|
+
|
|
83
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
84
|
+
if (e.key === 'Escape') close();
|
|
85
|
+
if (e.key === 'Enter' && !e.shiftKey && e.target instanceof HTMLInputElement) { e.preventDefault(); handleCreate(); }
|
|
86
|
+
}, [close, handleCreate]);
|
|
87
|
+
|
|
88
|
+
if (!open) return null;
|
|
89
|
+
|
|
90
|
+
return createPortal(
|
|
91
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center" onKeyDown={handleKeyDown}>
|
|
92
|
+
{/* Backdrop */}
|
|
93
|
+
<div className="absolute inset-0 bg-black/40 dark:bg-black/60" onClick={close} />
|
|
94
|
+
{/* Dialog */}
|
|
95
|
+
<div
|
|
96
|
+
role="dialog"
|
|
97
|
+
aria-modal="true"
|
|
98
|
+
aria-label={t.home.newSpace}
|
|
99
|
+
className="relative w-full max-w-md mx-4 rounded-2xl border border-border bg-card shadow-xl"
|
|
100
|
+
>
|
|
101
|
+
{/* Header */}
|
|
102
|
+
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
|
103
|
+
<h3 className="text-sm font-semibold font-display text-foreground">{t.home.newSpace}</h3>
|
|
104
|
+
<button onClick={close} className="p-1 rounded-md text-muted-foreground hover:bg-muted transition-colors">
|
|
105
|
+
<X size={14} />
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
{/* Body */}
|
|
109
|
+
<div className="px-5 pb-5 flex flex-col gap-3">
|
|
110
|
+
{/* Location — hierarchical browser */}
|
|
111
|
+
<div className="space-y-1">
|
|
112
|
+
<label className="text-xs font-medium text-muted-foreground">{t.home.spaceLocation ?? 'Location'}</label>
|
|
113
|
+
<DirPicker
|
|
114
|
+
dirPaths={dirPaths}
|
|
115
|
+
value={parent}
|
|
116
|
+
onChange={setParent}
|
|
117
|
+
rootLabel={t.home.rootLevel ?? 'Root'}
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
{/* Name */}
|
|
121
|
+
<div className="space-y-1">
|
|
122
|
+
<label className="text-xs font-medium text-muted-foreground">{t.home.spaceName}</label>
|
|
123
|
+
<input
|
|
124
|
+
ref={inputRef}
|
|
125
|
+
type="text"
|
|
126
|
+
value={name}
|
|
127
|
+
onChange={e => { setName(e.target.value); setError(''); validateName(e.target.value); }}
|
|
128
|
+
placeholder="e.g. 📝 Notes"
|
|
129
|
+
maxLength={80}
|
|
130
|
+
aria-invalid={!!nameHint}
|
|
131
|
+
aria-describedby={nameHint ? 'space-name-hint' : undefined}
|
|
132
|
+
className={`w-full px-3 py-2 text-sm rounded-lg border bg-background outline-none transition-colors ${
|
|
133
|
+
nameHint ? 'border-error focus:border-error' : 'border-border focus-visible:ring-1 focus-visible:ring-ring'
|
|
134
|
+
}`}
|
|
135
|
+
/>
|
|
136
|
+
{nameHint && <span id="space-name-hint" className="text-xs text-error">{nameHint}</span>}
|
|
137
|
+
</div>
|
|
138
|
+
{/* Description */}
|
|
139
|
+
<div className="space-y-1">
|
|
140
|
+
<label className="text-xs font-medium text-muted-foreground">
|
|
141
|
+
{t.home.spaceDescription} <span className="opacity-50">({t.home.optional ?? 'optional'})</span>
|
|
142
|
+
</label>
|
|
143
|
+
<input
|
|
144
|
+
type="text"
|
|
145
|
+
value={description}
|
|
146
|
+
onChange={e => setDescription(e.target.value)}
|
|
147
|
+
placeholder={t.home.spaceDescPlaceholder ?? 'Describe the purpose of this space'}
|
|
148
|
+
maxLength={200}
|
|
149
|
+
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring transition-colors"
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
{/* Path preview */}
|
|
153
|
+
{fullPathPreview && (
|
|
154
|
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground font-mono px-1">
|
|
155
|
+
<Folder size={12} className="shrink-0 text-[var(--amber)]" />
|
|
156
|
+
<span className="truncate">{fullPathPreview}</span>
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
{error && <span role="alert" className="text-xs text-error px-1">{error}</span>}
|
|
160
|
+
{/* Actions */}
|
|
161
|
+
<div className="flex items-center justify-end gap-2 pt-1">
|
|
162
|
+
<button
|
|
163
|
+
onClick={close}
|
|
164
|
+
className="px-4 py-2 rounded-lg text-sm font-medium text-muted-foreground transition-colors hover:bg-muted"
|
|
165
|
+
>
|
|
166
|
+
{t.home.cancelCreate}
|
|
167
|
+
</button>
|
|
168
|
+
<button
|
|
169
|
+
onClick={handleCreate}
|
|
170
|
+
disabled={!name.trim() || loading || !!nameHint}
|
|
171
|
+
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-[var(--amber)] text-white transition-colors hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
172
|
+
>
|
|
173
|
+
{loading && <Loader2 size={14} className="animate-spin" />}
|
|
174
|
+
{t.home.createSpace}
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</div>,
|
|
180
|
+
document.body,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
4
|
+
import { Folder, ChevronDown, ChevronRight } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
interface DirPickerProps {
|
|
7
|
+
/** Flat list of all directory paths (e.g. ['Notes', 'Notes/Daily', 'Projects']) */
|
|
8
|
+
dirPaths: string[];
|
|
9
|
+
/** Currently selected path ('' = root) */
|
|
10
|
+
value: string;
|
|
11
|
+
/** Called when user selects a directory */
|
|
12
|
+
onChange: (path: string) => void;
|
|
13
|
+
/** Label for root level */
|
|
14
|
+
rootLabel?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hierarchical directory picker — collapsed by default as a single-line button,
|
|
19
|
+
* expands into a mini file browser with breadcrumb navigation.
|
|
20
|
+
*/
|
|
21
|
+
export default function DirPicker({ dirPaths, value, onChange, rootLabel = 'Root' }: DirPickerProps) {
|
|
22
|
+
const [expanded, setExpanded] = useState(false);
|
|
23
|
+
const [browsing, setBrowsing] = useState(value);
|
|
24
|
+
|
|
25
|
+
useEffect(() => { setBrowsing(value); }, [value]);
|
|
26
|
+
|
|
27
|
+
const children = useMemo(() => {
|
|
28
|
+
const prefix = browsing ? browsing + '/' : '';
|
|
29
|
+
return dirPaths
|
|
30
|
+
.filter(p => {
|
|
31
|
+
if (!p.startsWith(prefix)) return false;
|
|
32
|
+
const rest = p.slice(prefix.length);
|
|
33
|
+
return rest.length > 0 && !rest.includes('/');
|
|
34
|
+
})
|
|
35
|
+
.sort();
|
|
36
|
+
}, [dirPaths, browsing]);
|
|
37
|
+
|
|
38
|
+
const segments = useMemo(() => browsing ? browsing.split('/') : [], [browsing]);
|
|
39
|
+
|
|
40
|
+
const navigateTo = (idx: number) => {
|
|
41
|
+
if (idx < 0) { setBrowsing(''); onChange(''); }
|
|
42
|
+
else { const p = segments.slice(0, idx + 1).join('/'); setBrowsing(p); onChange(p); }
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const drillInto = (childPath: string) => {
|
|
46
|
+
setBrowsing(childPath);
|
|
47
|
+
onChange(childPath);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const displayLabel = value
|
|
51
|
+
? value.split('/').map(s => s.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '') || s).join(' / ')
|
|
52
|
+
: '/ ' + rootLabel;
|
|
53
|
+
|
|
54
|
+
if (!expanded) {
|
|
55
|
+
return (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={() => setExpanded(true)}
|
|
59
|
+
className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-border bg-background text-foreground hover:border-amber-500/40 transition-colors text-left"
|
|
60
|
+
>
|
|
61
|
+
<Folder size={14} className="shrink-0 text-[var(--amber)]" />
|
|
62
|
+
<span className="flex-1 truncate">{displayLabel}</span>
|
|
63
|
+
<ChevronDown size={14} className="shrink-0 text-muted-foreground" />
|
|
64
|
+
</button>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="rounded-lg border border-[var(--amber)] bg-background overflow-hidden max-h-[200px] flex flex-col">
|
|
70
|
+
{/* Breadcrumb */}
|
|
71
|
+
<div className="flex items-center gap-0.5 px-3 py-1.5 bg-muted/30 border-b border-border overflow-x-auto text-xs shrink-0">
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
onClick={() => navigateTo(-1)}
|
|
75
|
+
className={`shrink-0 px-1.5 py-0.5 rounded transition-colors ${
|
|
76
|
+
browsing === '' ? 'text-[var(--amber)] font-medium' : 'text-muted-foreground hover:text-foreground'
|
|
77
|
+
}`}
|
|
78
|
+
>
|
|
79
|
+
/ {rootLabel}
|
|
80
|
+
</button>
|
|
81
|
+
{segments.map((seg, i) => (
|
|
82
|
+
<span key={i} className="flex items-center gap-0.5 shrink-0">
|
|
83
|
+
<ChevronRight size={10} className="text-muted-foreground/50" />
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
onClick={() => navigateTo(i)}
|
|
87
|
+
className={`px-1.5 py-0.5 rounded transition-colors truncate max-w-[100px] ${
|
|
88
|
+
i === segments.length - 1 ? 'text-[var(--amber)] font-medium' : 'text-muted-foreground hover:text-foreground'
|
|
89
|
+
}`}
|
|
90
|
+
>
|
|
91
|
+
{seg}
|
|
92
|
+
</button>
|
|
93
|
+
</span>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
{/* Child directories */}
|
|
97
|
+
{children.length > 0 ? (
|
|
98
|
+
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
99
|
+
{children.map(childPath => {
|
|
100
|
+
const childName = childPath.split('/').pop() || childPath;
|
|
101
|
+
const hasChildren = dirPaths.some(p => p.startsWith(childPath + '/'));
|
|
102
|
+
return (
|
|
103
|
+
<button
|
|
104
|
+
key={childPath}
|
|
105
|
+
type="button"
|
|
106
|
+
onClick={() => drillInto(childPath)}
|
|
107
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-foreground hover:bg-muted/60 transition-colors"
|
|
108
|
+
>
|
|
109
|
+
<Folder size={12} className="shrink-0 text-[var(--amber)]" />
|
|
110
|
+
<span className="flex-1 text-left truncate">{childName}</span>
|
|
111
|
+
{hasChildren && <ChevronRight size={11} className="shrink-0 text-muted-foreground/40" />}
|
|
112
|
+
</button>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
115
|
+
</div>
|
|
116
|
+
) : (
|
|
117
|
+
<div className="px-3 py-2 text-xs text-muted-foreground/50 text-center">—</div>
|
|
118
|
+
)}
|
|
119
|
+
{/* Collapse */}
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
onClick={() => setExpanded(false)}
|
|
123
|
+
className="w-full py-1.5 text-xs font-medium text-[var(--amber)] border-t border-border hover:bg-muted/30 transition-colors shrink-0"
|
|
124
|
+
>
|
|
125
|
+
✓
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|