@geminilight/mindos 0.2.0 → 0.3.0
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/README.md +3 -3
- package/README_zh.md +38 -5
- package/app/README.md +1 -1
- package/app/app/api/init/route.ts +22 -0
- package/app/app/api/settings/route.ts +3 -0
- package/app/app/api/setup/generate-token/route.ts +23 -0
- package/app/app/api/setup/route.ts +81 -0
- package/app/app/layout.tsx +10 -1
- package/app/app/page.tsx +5 -0
- package/app/app/register-sw.tsx +15 -0
- package/app/app/setup/page.tsx +9 -0
- package/app/components/HomeContent.tsx +8 -2
- package/app/components/OnboardingView.tsx +161 -0
- package/app/components/SettingsModal.tsx +7 -1
- package/app/components/SetupWizard.tsx +479 -0
- package/app/components/Sidebar.tsx +28 -4
- package/app/components/SyncStatusBar.tsx +273 -0
- package/app/components/renderers/AgentInspectorRenderer.tsx +8 -5
- package/app/components/settings/SyncTab.tsx +113 -21
- package/app/lib/agent/log.ts +44 -0
- package/app/lib/agent/tools.ts +39 -18
- package/app/lib/i18n.ts +170 -0
- package/app/lib/renderers/index.ts +13 -0
- package/app/lib/settings.ts +13 -2
- package/app/lib/template.ts +45 -0
- package/app/public/icons/icon-192.png +0 -0
- package/app/public/icons/icon-512.png +0 -0
- package/app/public/manifest.json +26 -0
- package/app/public/sw.js +66 -0
- package/bin/cli.js +21 -0
- package/bin/lib/mcp-install.js +225 -70
- package/bin/lib/startup.js +24 -1
- package/mcp/src/index.ts +37 -10
- package/package.json +1 -1
- package/scripts/setup.js +105 -2
- package/templates/README.md +1 -1
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { Sparkles, Globe, BookOpen, FileText, Copy, Check, RefreshCw, Loader2, ChevronLeft, ChevronRight, AlertTriangle } from 'lucide-react';
|
|
6
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
7
|
+
import { Field, Input, Select, ApiKeyInput } from '@/components/settings/Primitives';
|
|
8
|
+
|
|
9
|
+
type Template = 'en' | 'zh' | 'empty' | '';
|
|
10
|
+
|
|
11
|
+
interface SetupState {
|
|
12
|
+
mindRoot: string;
|
|
13
|
+
template: Template;
|
|
14
|
+
provider: 'anthropic' | 'openai' | 'skip';
|
|
15
|
+
anthropicKey: string;
|
|
16
|
+
anthropicModel: string;
|
|
17
|
+
openaiKey: string;
|
|
18
|
+
openaiModel: string;
|
|
19
|
+
openaiBaseUrl: string;
|
|
20
|
+
webPort: number;
|
|
21
|
+
mcpPort: number;
|
|
22
|
+
authToken: string;
|
|
23
|
+
webPassword: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TEMPLATES: Array<{
|
|
27
|
+
id: Template;
|
|
28
|
+
icon: React.ReactNode;
|
|
29
|
+
dirs: string[];
|
|
30
|
+
}> = [
|
|
31
|
+
{ id: 'en', icon: <Globe size={18} />, dirs: ['Profile/', 'Connections/', 'Notes/', 'Workflows/', 'Resources/', 'Projects/'] },
|
|
32
|
+
{ id: 'zh', icon: <BookOpen size={18} />, dirs: ['画像/', '关系/', '笔记/', '流程/', '资源/', '项目/'] },
|
|
33
|
+
{ id: 'empty', icon: <FileText size={18} />, dirs: ['README.md', 'CONFIG.json', 'INSTRUCTION.md'] },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const TOTAL_STEPS = 5;
|
|
37
|
+
|
|
38
|
+
export default function SetupWizard() {
|
|
39
|
+
const { t } = useLocale();
|
|
40
|
+
const router = useRouter();
|
|
41
|
+
const s = t.setup;
|
|
42
|
+
|
|
43
|
+
const [step, setStep] = useState(0);
|
|
44
|
+
const [state, setState] = useState<SetupState>({
|
|
45
|
+
mindRoot: '~/MindOS',
|
|
46
|
+
template: 'en',
|
|
47
|
+
provider: 'anthropic',
|
|
48
|
+
anthropicKey: '',
|
|
49
|
+
anthropicModel: 'claude-sonnet-4-6',
|
|
50
|
+
openaiKey: '',
|
|
51
|
+
openaiModel: 'gpt-5.4',
|
|
52
|
+
openaiBaseUrl: '',
|
|
53
|
+
webPort: 3000,
|
|
54
|
+
mcpPort: 8787,
|
|
55
|
+
authToken: '',
|
|
56
|
+
webPassword: '',
|
|
57
|
+
});
|
|
58
|
+
const [tokenCopied, setTokenCopied] = useState(false);
|
|
59
|
+
const [submitting, setSubmitting] = useState(false);
|
|
60
|
+
const [error, setError] = useState('');
|
|
61
|
+
const [portChanged, setPortChanged] = useState(false);
|
|
62
|
+
|
|
63
|
+
// Generate token on mount
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
|
66
|
+
.then(r => r.json())
|
|
67
|
+
.then(data => { if (data.token) setState(prev => ({ ...prev, authToken: data.token })); })
|
|
68
|
+
.catch(() => {});
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const update = useCallback(<K extends keyof SetupState>(key: K, val: SetupState[K]) => {
|
|
72
|
+
setState(prev => ({ ...prev, [key]: val }));
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
const generateToken = async (seed?: string) => {
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch('/api/setup/generate-token', {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify({ seed: seed || undefined }),
|
|
81
|
+
});
|
|
82
|
+
const data = await res.json();
|
|
83
|
+
if (data.token) update('authToken', data.token);
|
|
84
|
+
} catch { /* ignore */ }
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const copyToken = () => {
|
|
88
|
+
navigator.clipboard.writeText(state.authToken);
|
|
89
|
+
setTokenCopied(true);
|
|
90
|
+
setTimeout(() => setTokenCopied(false), 2000);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const handleComplete = async () => {
|
|
94
|
+
setSubmitting(true);
|
|
95
|
+
setError('');
|
|
96
|
+
try {
|
|
97
|
+
const payload = {
|
|
98
|
+
mindRoot: state.mindRoot.startsWith('~')
|
|
99
|
+
? state.mindRoot // server will resolve
|
|
100
|
+
: state.mindRoot,
|
|
101
|
+
template: state.template || undefined,
|
|
102
|
+
port: state.webPort,
|
|
103
|
+
mcpPort: state.mcpPort,
|
|
104
|
+
authToken: state.authToken,
|
|
105
|
+
webPassword: state.webPassword,
|
|
106
|
+
ai: state.provider === 'skip' ? undefined : {
|
|
107
|
+
provider: state.provider,
|
|
108
|
+
providers: {
|
|
109
|
+
anthropic: { apiKey: state.anthropicKey, model: state.anthropicModel },
|
|
110
|
+
openai: { apiKey: state.openaiKey, model: state.openaiModel, baseUrl: state.openaiBaseUrl },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
const res = await fetch('/api/setup', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
body: JSON.stringify(payload),
|
|
118
|
+
});
|
|
119
|
+
const data = await res.json();
|
|
120
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
121
|
+
if (data.portChanged) setPortChanged(true);
|
|
122
|
+
else router.push('/');
|
|
123
|
+
} catch (e) {
|
|
124
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
125
|
+
} finally {
|
|
126
|
+
setSubmitting(false);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const canNext = () => {
|
|
131
|
+
if (step === 0) return state.mindRoot.trim().length > 0;
|
|
132
|
+
if (step === 2) return state.webPort >= 1024 && state.webPort <= 65535 && state.mcpPort >= 1024 && state.mcpPort <= 65535;
|
|
133
|
+
return true;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const maskKey = (key: string) => {
|
|
137
|
+
if (!key) return '(not set)';
|
|
138
|
+
if (key.length <= 8) return '•••';
|
|
139
|
+
return key.slice(0, 6) + '•••' + key.slice(-3);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Step indicator dots
|
|
143
|
+
const StepDots = () => (
|
|
144
|
+
<div className="flex items-center gap-2 mb-8">
|
|
145
|
+
{s.stepTitles.map((title: string, i: number) => (
|
|
146
|
+
<div key={i} className="flex items-center gap-2">
|
|
147
|
+
{i > 0 && <div className="w-8 h-px" style={{ background: i <= step ? 'var(--amber)' : 'var(--border)' }} />}
|
|
148
|
+
<button
|
|
149
|
+
onClick={() => i < step && setStep(i)}
|
|
150
|
+
className="flex items-center gap-1.5"
|
|
151
|
+
disabled={i > step}
|
|
152
|
+
>
|
|
153
|
+
<div
|
|
154
|
+
className="w-6 h-6 rounded-full text-xs font-medium flex items-center justify-center transition-colors"
|
|
155
|
+
style={{
|
|
156
|
+
background: i === step ? 'var(--amber)' : i < step ? 'var(--amber)' : 'var(--muted)',
|
|
157
|
+
color: i <= step ? 'white' : 'var(--muted-foreground)',
|
|
158
|
+
opacity: i <= step ? 1 : 0.5,
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
{i + 1}
|
|
162
|
+
</div>
|
|
163
|
+
<span
|
|
164
|
+
className="text-xs hidden sm:inline"
|
|
165
|
+
style={{ color: i === step ? 'var(--foreground)' : 'var(--muted-foreground)', opacity: i <= step ? 1 : 0.5 }}
|
|
166
|
+
>
|
|
167
|
+
{title}
|
|
168
|
+
</span>
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Step 1: Knowledge Base
|
|
176
|
+
const Step1 = () => (
|
|
177
|
+
<div className="space-y-6">
|
|
178
|
+
<Field label={s.kbPath} hint={s.kbPathHint}>
|
|
179
|
+
<Input
|
|
180
|
+
value={state.mindRoot}
|
|
181
|
+
onChange={e => update('mindRoot', e.target.value)}
|
|
182
|
+
placeholder={s.kbPathDefault}
|
|
183
|
+
/>
|
|
184
|
+
</Field>
|
|
185
|
+
<div>
|
|
186
|
+
<label className="text-sm text-foreground font-medium mb-3 block">{s.template}</label>
|
|
187
|
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
188
|
+
{TEMPLATES.map(tpl => (
|
|
189
|
+
<button
|
|
190
|
+
key={tpl.id}
|
|
191
|
+
onClick={() => update('template', tpl.id)}
|
|
192
|
+
className="flex flex-col items-start gap-2 p-4 rounded-xl border text-left transition-all duration-150"
|
|
193
|
+
style={{
|
|
194
|
+
background: state.template === tpl.id ? 'var(--amber-subtle, rgba(200,135,30,0.08))' : 'var(--card)',
|
|
195
|
+
borderColor: state.template === tpl.id ? 'var(--amber)' : 'var(--border)',
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
<div className="flex items-center gap-2">
|
|
199
|
+
<span style={{ color: 'var(--amber)' }}>{tpl.icon}</span>
|
|
200
|
+
<span className="text-sm font-medium" style={{ color: 'var(--foreground)' }}>
|
|
201
|
+
{t.onboarding.templates[tpl.id as 'en' | 'zh' | 'empty'].title}
|
|
202
|
+
</span>
|
|
203
|
+
</div>
|
|
204
|
+
<div
|
|
205
|
+
className="w-full rounded-lg px-2.5 py-1.5 text-[11px] leading-relaxed"
|
|
206
|
+
style={{ background: 'var(--muted)', fontFamily: "'IBM Plex Mono', monospace", color: 'var(--muted-foreground)' }}
|
|
207
|
+
>
|
|
208
|
+
{tpl.dirs.map(d => <div key={d}>{d}</div>)}
|
|
209
|
+
</div>
|
|
210
|
+
</button>
|
|
211
|
+
))}
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Step 2: AI Provider
|
|
218
|
+
const Step2 = () => (
|
|
219
|
+
<div className="space-y-5">
|
|
220
|
+
<Field label={s.aiProvider} hint={s.aiProviderHint}>
|
|
221
|
+
<Select value={state.provider} onChange={e => update('provider', e.target.value as SetupState['provider'])}>
|
|
222
|
+
<option value="anthropic">Anthropic</option>
|
|
223
|
+
<option value="openai">OpenAI</option>
|
|
224
|
+
<option value="skip">{s.aiSkip}</option>
|
|
225
|
+
</Select>
|
|
226
|
+
</Field>
|
|
227
|
+
{state.provider !== 'skip' && (
|
|
228
|
+
<>
|
|
229
|
+
<Field label={s.apiKey}>
|
|
230
|
+
<ApiKeyInput
|
|
231
|
+
value={state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey}
|
|
232
|
+
onChange={v => update(state.provider === 'anthropic' ? 'anthropicKey' : 'openaiKey', v)}
|
|
233
|
+
placeholder={state.provider === 'anthropic' ? 'sk-ant-...' : 'sk-...'}
|
|
234
|
+
/>
|
|
235
|
+
</Field>
|
|
236
|
+
<Field label={s.model}>
|
|
237
|
+
<Input
|
|
238
|
+
value={state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel}
|
|
239
|
+
onChange={e => update(state.provider === 'anthropic' ? 'anthropicModel' : 'openaiModel', e.target.value)}
|
|
240
|
+
/>
|
|
241
|
+
</Field>
|
|
242
|
+
{state.provider === 'openai' && (
|
|
243
|
+
<Field label={s.baseUrl} hint={s.baseUrlHint}>
|
|
244
|
+
<Input
|
|
245
|
+
value={state.openaiBaseUrl}
|
|
246
|
+
onChange={e => update('openaiBaseUrl', e.target.value)}
|
|
247
|
+
placeholder="https://api.openai.com/v1"
|
|
248
|
+
/>
|
|
249
|
+
</Field>
|
|
250
|
+
)}
|
|
251
|
+
</>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// Step 3: Ports
|
|
257
|
+
const Step3 = () => (
|
|
258
|
+
<div className="space-y-5">
|
|
259
|
+
<Field label={s.webPort} hint={s.portHint}>
|
|
260
|
+
<Input
|
|
261
|
+
type="number"
|
|
262
|
+
min={1024}
|
|
263
|
+
max={65535}
|
|
264
|
+
value={state.webPort}
|
|
265
|
+
onChange={e => update('webPort', parseInt(e.target.value, 10) || 3000)}
|
|
266
|
+
/>
|
|
267
|
+
</Field>
|
|
268
|
+
<Field label={s.mcpPort} hint={s.portHint}>
|
|
269
|
+
<Input
|
|
270
|
+
type="number"
|
|
271
|
+
min={1024}
|
|
272
|
+
max={65535}
|
|
273
|
+
value={state.mcpPort}
|
|
274
|
+
onChange={e => update('mcpPort', parseInt(e.target.value, 10) || 8787)}
|
|
275
|
+
/>
|
|
276
|
+
</Field>
|
|
277
|
+
<p className="text-xs flex items-center gap-1.5" style={{ color: 'var(--muted-foreground)' }}>
|
|
278
|
+
<AlertTriangle size={12} />
|
|
279
|
+
{s.portRestartWarning}
|
|
280
|
+
</p>
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// Step 4: Security
|
|
285
|
+
const Step4 = () => {
|
|
286
|
+
const [seed, setSeed] = useState('');
|
|
287
|
+
const [showSeed, setShowSeed] = useState(false);
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<div className="space-y-5">
|
|
291
|
+
<Field label={s.authToken} hint={s.authTokenHint}>
|
|
292
|
+
<div className="flex gap-2">
|
|
293
|
+
<Input value={state.authToken} readOnly className="font-mono text-xs" />
|
|
294
|
+
<button
|
|
295
|
+
onClick={copyToken}
|
|
296
|
+
className="flex items-center gap-1 px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
|
|
297
|
+
style={{ color: 'var(--foreground)' }}
|
|
298
|
+
>
|
|
299
|
+
{tokenCopied ? <Check size={14} /> : <Copy size={14} />}
|
|
300
|
+
{tokenCopied ? s.copiedToken : s.copyToken}
|
|
301
|
+
</button>
|
|
302
|
+
<button
|
|
303
|
+
onClick={() => generateToken()}
|
|
304
|
+
className="flex items-center gap-1 px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
|
|
305
|
+
style={{ color: 'var(--foreground)' }}
|
|
306
|
+
>
|
|
307
|
+
<RefreshCw size={14} />
|
|
308
|
+
</button>
|
|
309
|
+
</div>
|
|
310
|
+
</Field>
|
|
311
|
+
|
|
312
|
+
<div>
|
|
313
|
+
<button
|
|
314
|
+
onClick={() => setShowSeed(!showSeed)}
|
|
315
|
+
className="text-xs underline"
|
|
316
|
+
style={{ color: 'var(--muted-foreground)' }}
|
|
317
|
+
>
|
|
318
|
+
{s.authTokenSeed}
|
|
319
|
+
</button>
|
|
320
|
+
{showSeed && (
|
|
321
|
+
<div className="mt-2 flex gap-2">
|
|
322
|
+
<Input
|
|
323
|
+
value={seed}
|
|
324
|
+
onChange={e => setSeed(e.target.value)}
|
|
325
|
+
placeholder={s.authTokenSeedHint}
|
|
326
|
+
/>
|
|
327
|
+
<button
|
|
328
|
+
onClick={() => { if (seed.trim()) generateToken(seed); }}
|
|
329
|
+
className="px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
|
|
330
|
+
style={{ color: 'var(--foreground)' }}
|
|
331
|
+
>
|
|
332
|
+
{s.generateToken}
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
<Field label={s.webPassword} hint={s.webPasswordHint}>
|
|
339
|
+
<Input
|
|
340
|
+
type="password"
|
|
341
|
+
value={state.webPassword}
|
|
342
|
+
onChange={e => update('webPassword', e.target.value)}
|
|
343
|
+
placeholder="(optional)"
|
|
344
|
+
/>
|
|
345
|
+
</Field>
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// Step 5: Review
|
|
351
|
+
const Step5 = () => {
|
|
352
|
+
const rows: [string, string][] = [
|
|
353
|
+
[s.kbPath, state.mindRoot],
|
|
354
|
+
[s.template, state.template || '—'],
|
|
355
|
+
[s.aiProvider, state.provider],
|
|
356
|
+
...(state.provider !== 'skip' ? [
|
|
357
|
+
[s.apiKey, maskKey(state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey)] as [string, string],
|
|
358
|
+
[s.model, state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel] as [string, string],
|
|
359
|
+
] : []),
|
|
360
|
+
[s.webPort, String(state.webPort)],
|
|
361
|
+
[s.mcpPort, String(state.mcpPort)],
|
|
362
|
+
[s.authToken, state.authToken || '—'],
|
|
363
|
+
[s.webPassword, state.webPassword ? '••••••••' : '(none)'],
|
|
364
|
+
];
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
<div className="space-y-5">
|
|
368
|
+
<p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.reviewHint}</p>
|
|
369
|
+
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border)' }}>
|
|
370
|
+
{rows.map(([label, value], i) => (
|
|
371
|
+
<div
|
|
372
|
+
key={i}
|
|
373
|
+
className="flex items-center justify-between px-4 py-3 text-sm"
|
|
374
|
+
style={{
|
|
375
|
+
background: i % 2 === 0 ? 'var(--card)' : 'transparent',
|
|
376
|
+
borderTop: i > 0 ? '1px solid var(--border)' : undefined,
|
|
377
|
+
}}
|
|
378
|
+
>
|
|
379
|
+
<span style={{ color: 'var(--muted-foreground)' }}>{label}</span>
|
|
380
|
+
<span className="font-mono text-xs" style={{ color: 'var(--foreground)' }}>{value}</span>
|
|
381
|
+
</div>
|
|
382
|
+
))}
|
|
383
|
+
</div>
|
|
384
|
+
|
|
385
|
+
{error && (
|
|
386
|
+
<div className="p-3 rounded-lg text-sm text-red-500" style={{ background: 'rgba(239,68,68,0.1)' }}>
|
|
387
|
+
{s.completeFailed}: {error}
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
|
|
391
|
+
{portChanged && (
|
|
392
|
+
<div className="space-y-3">
|
|
393
|
+
<div className="p-3 rounded-lg text-sm flex items-center gap-2" style={{ background: 'rgba(200,135,30,0.1)', color: 'var(--amber)' }}>
|
|
394
|
+
<AlertTriangle size={14} />
|
|
395
|
+
{s.portChanged}
|
|
396
|
+
</div>
|
|
397
|
+
<a
|
|
398
|
+
href="/"
|
|
399
|
+
className="inline-flex items-center gap-1 px-4 py-2 text-sm rounded-lg transition-colors"
|
|
400
|
+
style={{ background: 'var(--amber)', color: 'white' }}
|
|
401
|
+
>
|
|
402
|
+
{s.completeDone} →
|
|
403
|
+
</a>
|
|
404
|
+
</div>
|
|
405
|
+
)}
|
|
406
|
+
</div>
|
|
407
|
+
);
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const steps = [Step1, Step2, Step3, Step4, Step5];
|
|
411
|
+
const CurrentStep = steps[step];
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style={{ background: 'var(--background)' }}>
|
|
415
|
+
<div className="w-full max-w-xl mx-auto px-6 py-12">
|
|
416
|
+
{/* Header */}
|
|
417
|
+
<div className="text-center mb-8">
|
|
418
|
+
<div className="inline-flex items-center gap-2 mb-2">
|
|
419
|
+
<Sparkles size={18} style={{ color: 'var(--amber)' }} />
|
|
420
|
+
<h1
|
|
421
|
+
className="text-2xl font-semibold tracking-tight"
|
|
422
|
+
style={{ fontFamily: "'IBM Plex Mono', monospace", color: 'var(--foreground)' }}
|
|
423
|
+
>
|
|
424
|
+
MindOS
|
|
425
|
+
</h1>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
{/* Step dots */}
|
|
430
|
+
<div className="flex justify-center">
|
|
431
|
+
<StepDots />
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
{/* Step title */}
|
|
435
|
+
<h2 className="text-lg font-semibold mb-5" style={{ color: 'var(--foreground)' }}>
|
|
436
|
+
{s.stepTitles[step]}
|
|
437
|
+
</h2>
|
|
438
|
+
|
|
439
|
+
{/* Step content */}
|
|
440
|
+
<CurrentStep />
|
|
441
|
+
|
|
442
|
+
{/* Navigation */}
|
|
443
|
+
<div className="flex items-center justify-between mt-8 pt-6" style={{ borderTop: '1px solid var(--border)' }}>
|
|
444
|
+
<button
|
|
445
|
+
onClick={() => setStep(step - 1)}
|
|
446
|
+
disabled={step === 0}
|
|
447
|
+
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg border border-border hover:bg-muted transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
|
448
|
+
style={{ color: 'var(--foreground)' }}
|
|
449
|
+
>
|
|
450
|
+
<ChevronLeft size={14} />
|
|
451
|
+
{s.back}
|
|
452
|
+
</button>
|
|
453
|
+
|
|
454
|
+
{step < TOTAL_STEPS - 1 ? (
|
|
455
|
+
<button
|
|
456
|
+
onClick={() => setStep(step + 1)}
|
|
457
|
+
disabled={!canNext()}
|
|
458
|
+
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
|
459
|
+
style={{ background: 'var(--amber)', color: 'white' }}
|
|
460
|
+
>
|
|
461
|
+
{s.next}
|
|
462
|
+
<ChevronRight size={14} />
|
|
463
|
+
</button>
|
|
464
|
+
) : (
|
|
465
|
+
<button
|
|
466
|
+
onClick={handleComplete}
|
|
467
|
+
disabled={submitting || portChanged}
|
|
468
|
+
className="flex items-center gap-1 px-5 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
|
469
|
+
style={{ background: 'var(--amber)', color: 'white' }}
|
|
470
|
+
>
|
|
471
|
+
{submitting && <Loader2 size={14} className="animate-spin" />}
|
|
472
|
+
{submitting ? s.completing : portChanged ? s.completeDone : s.complete}
|
|
473
|
+
</button>
|
|
474
|
+
)}
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
);
|
|
479
|
+
}
|
|
@@ -8,7 +8,9 @@ import FileTree from './FileTree';
|
|
|
8
8
|
import SearchModal from './SearchModal';
|
|
9
9
|
import AskModal from './AskModal';
|
|
10
10
|
import SettingsModal from './SettingsModal';
|
|
11
|
+
import SyncStatusBar, { SyncDot, MobileSyncDot, useSyncStatus } from './SyncStatusBar';
|
|
11
12
|
import { FileNode } from '@/lib/types';
|
|
13
|
+
import type { Tab } from './settings/types';
|
|
12
14
|
import { useLocale } from '@/lib/LocaleContext';
|
|
13
15
|
|
|
14
16
|
interface SidebarProps {
|
|
@@ -40,9 +42,13 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
|
|
|
40
42
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
41
43
|
const [askOpen, setAskOpen] = useState(false);
|
|
42
44
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
45
|
+
const [settingsTab, setSettingsTab] = useState<Tab | undefined>(undefined);
|
|
43
46
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
44
47
|
const { t } = useLocale();
|
|
45
48
|
|
|
49
|
+
// Shared sync status for collapsed dot & mobile dot
|
|
50
|
+
const { status: syncStatus } = useSyncStatus();
|
|
51
|
+
|
|
46
52
|
const pathname = usePathname();
|
|
47
53
|
const currentFile = pathname.startsWith('/view/')
|
|
48
54
|
? pathname.slice('/view/'.length).split('/').map(decodeURIComponent).join('/')
|
|
@@ -60,6 +66,8 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
|
|
|
60
66
|
|
|
61
67
|
useEffect(() => { setMobileOpen(false); }, [pathname]);
|
|
62
68
|
|
|
69
|
+
const openSyncSettings = () => { setSettingsTab('sync'); setSettingsOpen(true); };
|
|
70
|
+
|
|
63
71
|
const sidebarContent = (
|
|
64
72
|
<div className="flex flex-col h-full">
|
|
65
73
|
<div className="flex items-center justify-between px-4 py-4 border-b border-border shrink-0">
|
|
@@ -88,6 +96,10 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
|
|
|
88
96
|
<div className="flex-1 overflow-y-auto min-h-0 px-2 py-2">
|
|
89
97
|
<FileTree nodes={fileTree} onNavigate={() => setMobileOpen(false)} />
|
|
90
98
|
</div>
|
|
99
|
+
<SyncStatusBar
|
|
100
|
+
collapsed={collapsed}
|
|
101
|
+
onOpenSyncSettings={openSyncSettings}
|
|
102
|
+
/>
|
|
91
103
|
</div>
|
|
92
104
|
);
|
|
93
105
|
|
|
@@ -97,10 +109,14 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
|
|
|
97
109
|
{sidebarContent}
|
|
98
110
|
</aside>
|
|
99
111
|
|
|
112
|
+
{/* #7 — Collapsed sidebar: expand button with sync health dot */}
|
|
100
113
|
{collapsed && (
|
|
101
|
-
<
|
|
102
|
-
<
|
|
103
|
-
|
|
114
|
+
<div className="hidden md:flex fixed top-4 left-0 z-30 flex-col items-center gap-2">
|
|
115
|
+
<button onClick={onExpand} className="relative flex items-center justify-center w-6 h-10 bg-card border border-border rounded-r-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" title={t.sidebar.expandTitle}>
|
|
116
|
+
<PanelLeftOpen size={14} />
|
|
117
|
+
<SyncDot status={syncStatus} />
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
104
120
|
)}
|
|
105
121
|
|
|
106
122
|
{/* Mobile navbar */}
|
|
@@ -113,6 +129,14 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
|
|
|
113
129
|
<span className="font-semibold text-foreground text-sm tracking-wide">MindOS</span>
|
|
114
130
|
</Link>
|
|
115
131
|
<div className="flex items-center gap-0.5">
|
|
132
|
+
{/* #8 — Mobile sync dot: visible when there's a problem */}
|
|
133
|
+
<button
|
|
134
|
+
onClick={openSyncSettings}
|
|
135
|
+
className="p-2.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors active:bg-accent flex items-center justify-center"
|
|
136
|
+
aria-label="Sync status"
|
|
137
|
+
>
|
|
138
|
+
<MobileSyncDot status={syncStatus} />
|
|
139
|
+
</button>
|
|
116
140
|
<button onClick={() => setSearchOpen(true)} className="p-2.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors active:bg-accent" aria-label={t.sidebar.searchTitle}>
|
|
117
141
|
<Search size={20} />
|
|
118
142
|
</button>
|
|
@@ -130,7 +154,7 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
|
|
|
130
154
|
|
|
131
155
|
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
|
|
132
156
|
<AskModal open={askOpen} onClose={() => setAskOpen(false)} currentFile={currentFile} />
|
|
133
|
-
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
|
157
|
+
<SettingsModal open={settingsOpen} onClose={() => { setSettingsOpen(false); setSettingsTab(undefined); }} initialTab={settingsTab} />
|
|
134
158
|
</>
|
|
135
159
|
);
|
|
136
160
|
}
|