@geminilight/mindos 0.5.27 → 0.5.29
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/api/update/route.ts +41 -0
- package/app/app/explore/page.tsx +12 -0
- package/app/components/ActivityBar.tsx +14 -7
- package/app/components/GuideCard.tsx +21 -7
- package/app/components/HomeContent.tsx +12 -1
- package/app/components/KeyboardShortcuts.tsx +102 -0
- package/app/components/Panel.tsx +12 -7
- package/app/components/SidebarLayout.tsx +18 -1
- package/app/components/UpdateBanner.tsx +19 -21
- package/app/components/explore/ExploreContent.tsx +100 -0
- package/app/components/explore/UseCaseCard.tsx +50 -0
- package/app/components/explore/use-cases.ts +30 -0
- package/app/components/panels/AgentsPanel.tsx +86 -95
- package/app/components/panels/PluginsPanel.tsx +9 -6
- package/app/components/settings/AiTab.tsx +5 -3
- package/app/components/settings/McpServerStatus.tsx +12 -6
- package/app/components/settings/SettingsContent.tsx +5 -2
- package/app/components/settings/UpdateTab.tsx +195 -0
- package/app/components/settings/types.ts +1 -1
- package/app/components/walkthrough/WalkthroughOverlay.tsx +224 -0
- package/app/components/walkthrough/WalkthroughProvider.tsx +133 -0
- package/app/components/walkthrough/WalkthroughTooltip.tsx +129 -0
- package/app/components/walkthrough/index.ts +3 -0
- package/app/components/walkthrough/steps.ts +21 -0
- package/app/lib/i18n-en.ts +168 -8
- package/app/lib/i18n-zh.ts +167 -7
- package/app/lib/settings.ts +4 -0
- package/app/next.config.ts +1 -1
- package/app/package.json +1 -0
- package/bin/lib/mcp-spawn.js +13 -2
- package/mcp/src/index.ts +3 -2
- package/package.json +1 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
+
import { Download, RefreshCw, CheckCircle2, AlertCircle, Loader2, ExternalLink } from 'lucide-react';
|
|
5
|
+
import { apiFetch } from '@/lib/api';
|
|
6
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
7
|
+
|
|
8
|
+
interface UpdateInfo {
|
|
9
|
+
current: string;
|
|
10
|
+
latest: string;
|
|
11
|
+
hasUpdate: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type UpdateState = 'idle' | 'checking' | 'updating' | 'updated' | 'error' | 'timeout';
|
|
15
|
+
|
|
16
|
+
const CHANGELOG_URL = 'https://github.com/GeminiLight/MindOS/releases';
|
|
17
|
+
const POLL_INTERVAL = 5_000;
|
|
18
|
+
const POLL_TIMEOUT = 4 * 60 * 1000; // 4 minutes
|
|
19
|
+
|
|
20
|
+
export function UpdateTab() {
|
|
21
|
+
const { t } = useLocale();
|
|
22
|
+
const u = t.settings.update;
|
|
23
|
+
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
|
24
|
+
const [state, setState] = useState<UpdateState>('idle');
|
|
25
|
+
const [errorMsg, setErrorMsg] = useState('');
|
|
26
|
+
const pollRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
|
27
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
28
|
+
const originalVersion = useRef<string>('');
|
|
29
|
+
|
|
30
|
+
const checkUpdate = useCallback(async () => {
|
|
31
|
+
setState('checking');
|
|
32
|
+
setErrorMsg('');
|
|
33
|
+
try {
|
|
34
|
+
const data = await apiFetch<UpdateInfo>('/api/update-check');
|
|
35
|
+
setInfo(data);
|
|
36
|
+
if (!originalVersion.current) originalVersion.current = data.current;
|
|
37
|
+
setState('idle');
|
|
38
|
+
} catch {
|
|
39
|
+
setState('error');
|
|
40
|
+
setErrorMsg(u?.error ?? 'Failed to check for updates.');
|
|
41
|
+
}
|
|
42
|
+
}, [u]);
|
|
43
|
+
|
|
44
|
+
useEffect(() => { checkUpdate(); }, [checkUpdate]);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
return () => {
|
|
48
|
+
clearInterval(pollRef.current);
|
|
49
|
+
clearTimeout(timeoutRef.current);
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const handleUpdate = useCallback(async () => {
|
|
54
|
+
setState('updating');
|
|
55
|
+
setErrorMsg('');
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await apiFetch('/api/update', { method: 'POST' });
|
|
59
|
+
} catch {
|
|
60
|
+
// Expected — server may die during update
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
pollRef.current = setInterval(async () => {
|
|
64
|
+
try {
|
|
65
|
+
const data = await apiFetch<UpdateInfo>('/api/update-check');
|
|
66
|
+
if (data.current !== originalVersion.current) {
|
|
67
|
+
clearInterval(pollRef.current);
|
|
68
|
+
clearTimeout(timeoutRef.current);
|
|
69
|
+
setInfo(data);
|
|
70
|
+
setState('updated');
|
|
71
|
+
setTimeout(() => window.location.reload(), 2000);
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// Server still restarting
|
|
75
|
+
}
|
|
76
|
+
}, POLL_INTERVAL);
|
|
77
|
+
|
|
78
|
+
timeoutRef.current = setTimeout(() => {
|
|
79
|
+
clearInterval(pollRef.current);
|
|
80
|
+
setState('timeout');
|
|
81
|
+
}, POLL_TIMEOUT);
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="space-y-5">
|
|
86
|
+
{/* Version Card */}
|
|
87
|
+
<div className="rounded-xl border border-border bg-card p-4 space-y-3">
|
|
88
|
+
<div className="flex items-center justify-between">
|
|
89
|
+
<span className="text-sm font-medium text-foreground">MindOS</span>
|
|
90
|
+
{info && (
|
|
91
|
+
<span className="text-xs font-mono text-muted-foreground">v{info.current}</span>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{state === 'checking' && (
|
|
96
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
97
|
+
<Loader2 size={13} className="animate-spin" />
|
|
98
|
+
{u?.checking ?? 'Checking for updates...'}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
{state === 'idle' && info && !info.hasUpdate && (
|
|
103
|
+
<div className="flex items-center gap-2 text-xs text-emerald-600 dark:text-emerald-400">
|
|
104
|
+
<CheckCircle2 size={13} />
|
|
105
|
+
{u?.upToDate ?? "You're up to date"}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
{state === 'idle' && info?.hasUpdate && (
|
|
110
|
+
<div className="flex items-center gap-2 text-xs" style={{ color: 'var(--amber)' }}>
|
|
111
|
+
<Download size={13} />
|
|
112
|
+
{u?.available ? u.available(info.current, info.latest) : `Update available: v${info.current} → v${info.latest}`}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{state === 'updating' && (
|
|
117
|
+
<div className="space-y-2">
|
|
118
|
+
<div className="flex items-center gap-2 text-xs" style={{ color: 'var(--amber)' }}>
|
|
119
|
+
<Loader2 size={13} className="animate-spin" />
|
|
120
|
+
{u?.updating ?? 'Updating MindOS... The server will restart shortly.'}
|
|
121
|
+
</div>
|
|
122
|
+
<p className="text-2xs text-muted-foreground">
|
|
123
|
+
{u?.updatingHint ?? 'This may take 1–3 minutes. Do not close this page.'}
|
|
124
|
+
</p>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{state === 'updated' && (
|
|
129
|
+
<div className="flex items-center gap-2 text-xs text-emerald-600 dark:text-emerald-400">
|
|
130
|
+
<CheckCircle2 size={13} />
|
|
131
|
+
{u?.updated ?? 'Updated successfully! Reloading...'}
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{state === 'timeout' && (
|
|
136
|
+
<div className="space-y-1">
|
|
137
|
+
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
|
|
138
|
+
<AlertCircle size={13} />
|
|
139
|
+
{u?.timeout ?? 'Update may still be in progress.'}
|
|
140
|
+
</div>
|
|
141
|
+
<p className="text-2xs text-muted-foreground">
|
|
142
|
+
{u?.timeoutHint ?? 'Check your terminal:'} <code className="font-mono bg-muted px-1 py-0.5 rounded">mindos logs</code>
|
|
143
|
+
</p>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{state === 'error' && (
|
|
148
|
+
<div className="flex items-center gap-2 text-xs text-destructive">
|
|
149
|
+
<AlertCircle size={13} />
|
|
150
|
+
{errorMsg}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{/* Actions */}
|
|
156
|
+
<div className="flex items-center gap-2">
|
|
157
|
+
<button
|
|
158
|
+
onClick={checkUpdate}
|
|
159
|
+
disabled={state === 'checking' || state === 'updating'}
|
|
160
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
161
|
+
>
|
|
162
|
+
<RefreshCw size={12} className={state === 'checking' ? 'animate-spin' : ''} />
|
|
163
|
+
{u?.checkButton ?? 'Check for Updates'}
|
|
164
|
+
</button>
|
|
165
|
+
|
|
166
|
+
{info?.hasUpdate && state !== 'updating' && state !== 'updated' && (
|
|
167
|
+
<button
|
|
168
|
+
onClick={handleUpdate}
|
|
169
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg font-medium text-white transition-colors"
|
|
170
|
+
style={{ background: 'var(--amber)' }}
|
|
171
|
+
>
|
|
172
|
+
<Download size={12} />
|
|
173
|
+
{u?.updateButton ? u.updateButton(info.latest) : `Update to v${info.latest}`}
|
|
174
|
+
</button>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Info */}
|
|
179
|
+
<div className="border-t border-border pt-4 space-y-2">
|
|
180
|
+
<a
|
|
181
|
+
href={CHANGELOG_URL}
|
|
182
|
+
target="_blank"
|
|
183
|
+
rel="noopener noreferrer"
|
|
184
|
+
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
185
|
+
>
|
|
186
|
+
<ExternalLink size={12} />
|
|
187
|
+
{u?.releaseNotes ?? 'View release notes'}
|
|
188
|
+
</a>
|
|
189
|
+
<p className="text-2xs text-muted-foreground/60">
|
|
190
|
+
{u?.hint ?? 'Updates are installed via npm. Equivalent to running'} <code className="font-mono bg-muted px-1 py-0.5 rounded">mindos update</code> {u?.inTerminal ?? 'in your terminal.'}
|
|
191
|
+
</p>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
@@ -33,7 +33,7 @@ export interface SettingsData {
|
|
|
33
33
|
envValues?: Record<string, string>;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export type Tab = 'ai' | 'appearance' | 'knowledge' | 'mcp' | 'sync';
|
|
36
|
+
export type Tab = 'ai' | 'appearance' | 'knowledge' | 'mcp' | 'sync' | 'update';
|
|
37
37
|
|
|
38
38
|
export const CONTENT_WIDTHS = [
|
|
39
39
|
{ value: '680px', label: 'Narrow (680px)' },
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useId } from 'react';
|
|
4
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
5
|
+
import { useWalkthrough } from './WalkthroughProvider';
|
|
6
|
+
import { walkthroughSteps } from './steps';
|
|
7
|
+
import WalkthroughTooltip from './WalkthroughTooltip';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Full-screen overlay with SVG spotlight mask.
|
|
11
|
+
* Finds the target element via data-walkthrough attribute, measures it,
|
|
12
|
+
* and cuts a transparent rect into the semi-transparent overlay.
|
|
13
|
+
*/
|
|
14
|
+
export default function WalkthroughOverlay() {
|
|
15
|
+
const wt = useWalkthrough();
|
|
16
|
+
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
|
|
17
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
18
|
+
const maskId = useId();
|
|
19
|
+
|
|
20
|
+
const step = wt ? walkthroughSteps[wt.currentStep] : null;
|
|
21
|
+
|
|
22
|
+
const measureTarget = useCallback(() => {
|
|
23
|
+
if (!step) return;
|
|
24
|
+
const el = document.querySelector(`[data-walkthrough="${step.anchor}"]`);
|
|
25
|
+
if (el) {
|
|
26
|
+
setTargetRect(el.getBoundingClientRect());
|
|
27
|
+
} else {
|
|
28
|
+
setTargetRect(null);
|
|
29
|
+
}
|
|
30
|
+
}, [step]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
setIsMobile(window.innerWidth < 768);
|
|
34
|
+
measureTarget();
|
|
35
|
+
|
|
36
|
+
const handleResize = () => {
|
|
37
|
+
setIsMobile(window.innerWidth < 768);
|
|
38
|
+
measureTarget();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
window.addEventListener('resize', handleResize);
|
|
42
|
+
window.addEventListener('scroll', measureTarget, true);
|
|
43
|
+
return () => {
|
|
44
|
+
window.removeEventListener('resize', handleResize);
|
|
45
|
+
window.removeEventListener('scroll', measureTarget, true);
|
|
46
|
+
};
|
|
47
|
+
}, [measureTarget]);
|
|
48
|
+
|
|
49
|
+
// Re-measure when step changes
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
measureTarget();
|
|
52
|
+
// Slight delay to account for animations
|
|
53
|
+
const timer = setTimeout(measureTarget, 100);
|
|
54
|
+
return () => clearTimeout(timer);
|
|
55
|
+
}, [wt?.currentStep, measureTarget]);
|
|
56
|
+
|
|
57
|
+
// ESC to dismiss — depend on skip only, not the entire context object
|
|
58
|
+
const skipFn = wt?.skip;
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!skipFn) return;
|
|
61
|
+
const handler = (e: KeyboardEvent) => {
|
|
62
|
+
if (e.key === 'Escape') {
|
|
63
|
+
e.stopPropagation();
|
|
64
|
+
skipFn();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
window.addEventListener('keydown', handler, true);
|
|
68
|
+
return () => window.removeEventListener('keydown', handler, true);
|
|
69
|
+
}, [skipFn]);
|
|
70
|
+
|
|
71
|
+
if (!wt || !step) return null;
|
|
72
|
+
|
|
73
|
+
// Mobile: bottom sheet instead of spotlight
|
|
74
|
+
if (isMobile) {
|
|
75
|
+
return <MobileWalkthroughSheet />;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const PAD = 6; // padding around spotlight rect
|
|
79
|
+
const RADIUS = 8;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<>
|
|
83
|
+
{/* SVG overlay with spotlight hole */}
|
|
84
|
+
<svg
|
|
85
|
+
className="fixed inset-0 z-[100] pointer-events-auto"
|
|
86
|
+
width="100%"
|
|
87
|
+
height="100%"
|
|
88
|
+
onClick={(e) => {
|
|
89
|
+
// Click outside spotlight → skip
|
|
90
|
+
if (targetRect) {
|
|
91
|
+
const x = e.clientX;
|
|
92
|
+
const y = e.clientY;
|
|
93
|
+
const inSpotlight =
|
|
94
|
+
x >= targetRect.left - PAD &&
|
|
95
|
+
x <= targetRect.right + PAD &&
|
|
96
|
+
y >= targetRect.top - PAD &&
|
|
97
|
+
y <= targetRect.bottom + PAD;
|
|
98
|
+
if (!inSpotlight) {
|
|
99
|
+
wt.skip();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
<defs>
|
|
105
|
+
<mask id={maskId}>
|
|
106
|
+
<rect width="100%" height="100%" fill="white" />
|
|
107
|
+
{targetRect && (
|
|
108
|
+
<rect
|
|
109
|
+
x={targetRect.left - PAD}
|
|
110
|
+
y={targetRect.top - PAD}
|
|
111
|
+
width={targetRect.width + PAD * 2}
|
|
112
|
+
height={targetRect.height + PAD * 2}
|
|
113
|
+
rx={RADIUS}
|
|
114
|
+
ry={RADIUS}
|
|
115
|
+
fill="black"
|
|
116
|
+
/>
|
|
117
|
+
)}
|
|
118
|
+
</mask>
|
|
119
|
+
</defs>
|
|
120
|
+
<rect
|
|
121
|
+
width="100%"
|
|
122
|
+
height="100%"
|
|
123
|
+
fill="rgba(0, 0, 0, 0.5)"
|
|
124
|
+
mask={`url(#${maskId})`}
|
|
125
|
+
/>
|
|
126
|
+
</svg>
|
|
127
|
+
|
|
128
|
+
{/* Tooltip */}
|
|
129
|
+
{targetRect && (
|
|
130
|
+
<WalkthroughTooltip
|
|
131
|
+
stepIndex={wt.currentStep}
|
|
132
|
+
rect={targetRect}
|
|
133
|
+
position={step.position}
|
|
134
|
+
/>
|
|
135
|
+
)}
|
|
136
|
+
</>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Mobile fallback: bottom sheet card */
|
|
141
|
+
function MobileWalkthroughSheet() {
|
|
142
|
+
const wt = useWalkthrough();
|
|
143
|
+
const { t } = useLocale();
|
|
144
|
+
|
|
145
|
+
if (!wt) return null;
|
|
146
|
+
|
|
147
|
+
const stepData = t.walkthrough.steps[wt.currentStep] as { title: string; body: string } | undefined;
|
|
148
|
+
if (!stepData) return null;
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<>
|
|
152
|
+
{/* Backdrop */}
|
|
153
|
+
<div
|
|
154
|
+
className="fixed inset-0 z-[100] bg-black/40 backdrop-blur-sm"
|
|
155
|
+
onClick={wt.skip}
|
|
156
|
+
/>
|
|
157
|
+
{/* Bottom sheet */}
|
|
158
|
+
<div
|
|
159
|
+
className="fixed bottom-0 left-0 right-0 z-[101] rounded-t-2xl border-t shadow-lg p-5 pb-8 animate-in slide-in-from-bottom duration-300"
|
|
160
|
+
style={{ background: 'var(--card)', borderColor: 'var(--border)' }}
|
|
161
|
+
>
|
|
162
|
+
{/* Step counter */}
|
|
163
|
+
<div className="flex items-center gap-2 mb-3">
|
|
164
|
+
<span
|
|
165
|
+
className="text-2xs font-mono px-1.5 py-0.5 rounded-full"
|
|
166
|
+
style={{ background: 'var(--amber-dim)', color: 'var(--amber)' }}
|
|
167
|
+
>
|
|
168
|
+
{t.walkthrough.step(wt.currentStep + 1, wt.totalSteps)}
|
|
169
|
+
</span>
|
|
170
|
+
{/* Progress dots */}
|
|
171
|
+
<div className="flex items-center gap-1 ml-auto">
|
|
172
|
+
{Array.from({ length: wt.totalSteps }, (_, i) => (
|
|
173
|
+
<div
|
|
174
|
+
key={i}
|
|
175
|
+
className="w-1.5 h-1.5 rounded-full"
|
|
176
|
+
style={{
|
|
177
|
+
background: i === wt.currentStep ? 'var(--amber)' : 'var(--border)',
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
))}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<h3
|
|
185
|
+
className="text-base font-semibold font-display mb-1"
|
|
186
|
+
style={{ color: 'var(--foreground)' }}
|
|
187
|
+
>
|
|
188
|
+
{stepData.title}
|
|
189
|
+
</h3>
|
|
190
|
+
<p className="text-sm leading-relaxed mb-5" style={{ color: 'var(--muted-foreground)' }}>
|
|
191
|
+
{stepData.body}
|
|
192
|
+
</p>
|
|
193
|
+
|
|
194
|
+
<div className="flex items-center justify-between">
|
|
195
|
+
<button
|
|
196
|
+
onClick={wt.skip}
|
|
197
|
+
className="text-sm transition-colors hover:opacity-80"
|
|
198
|
+
style={{ color: 'var(--muted-foreground)' }}
|
|
199
|
+
>
|
|
200
|
+
{t.walkthrough.skip}
|
|
201
|
+
</button>
|
|
202
|
+
<div className="flex items-center gap-2">
|
|
203
|
+
{wt.currentStep > 0 && (
|
|
204
|
+
<button
|
|
205
|
+
onClick={wt.back}
|
|
206
|
+
className="text-sm px-3 py-2 rounded-lg transition-colors hover:bg-muted"
|
|
207
|
+
style={{ color: 'var(--muted-foreground)' }}
|
|
208
|
+
>
|
|
209
|
+
{t.walkthrough.back}
|
|
210
|
+
</button>
|
|
211
|
+
)}
|
|
212
|
+
<button
|
|
213
|
+
onClick={wt.next}
|
|
214
|
+
className="text-sm px-4 py-2 rounded-lg font-medium transition-all hover:opacity-90"
|
|
215
|
+
style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
|
|
216
|
+
>
|
|
217
|
+
{wt.currentStep === wt.totalSteps - 1 ? t.walkthrough.done : t.walkthrough.next}
|
|
218
|
+
</button>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
|
|
4
|
+
import { walkthroughSteps } from './steps';
|
|
5
|
+
import WalkthroughOverlay from './WalkthroughOverlay';
|
|
6
|
+
|
|
7
|
+
type WalkthroughStatus = 'idle' | 'active' | 'completed' | 'dismissed';
|
|
8
|
+
|
|
9
|
+
interface WalkthroughContextValue {
|
|
10
|
+
status: WalkthroughStatus;
|
|
11
|
+
currentStep: number;
|
|
12
|
+
totalSteps: number;
|
|
13
|
+
start: () => void;
|
|
14
|
+
next: () => void;
|
|
15
|
+
back: () => void;
|
|
16
|
+
skip: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const WalkthroughContext = createContext<WalkthroughContextValue | null>(null);
|
|
20
|
+
|
|
21
|
+
export function useWalkthrough() {
|
|
22
|
+
return useContext(WalkthroughContext);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface WalkthroughProviderProps {
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function WalkthroughProvider({ children }: WalkthroughProviderProps) {
|
|
30
|
+
const [status, setStatus] = useState<WalkthroughStatus>('idle');
|
|
31
|
+
const [currentStep, setCurrentStep] = useState(0);
|
|
32
|
+
const totalSteps = walkthroughSteps.length;
|
|
33
|
+
|
|
34
|
+
// Persist to backend
|
|
35
|
+
const persistStep = useCallback((step: number, dismissed: boolean) => {
|
|
36
|
+
fetch('/api/setup', {
|
|
37
|
+
method: 'PATCH',
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
guideState: {
|
|
41
|
+
walkthroughStep: step,
|
|
42
|
+
walkthroughDismissed: dismissed,
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
}).catch(() => {});
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
// Check for auto-start via ?welcome=1 or guideState
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
// Handle ?welcome=1 URL param — single owner, clean up immediately
|
|
51
|
+
const params = new URLSearchParams(window.location.search);
|
|
52
|
+
const isWelcome = params.get('welcome') === '1';
|
|
53
|
+
if (isWelcome) {
|
|
54
|
+
const url = new URL(window.location.href);
|
|
55
|
+
url.searchParams.delete('welcome');
|
|
56
|
+
window.history.replaceState({}, '', url.pathname + (url.search || ''));
|
|
57
|
+
// Notify GuideCard about first visit
|
|
58
|
+
window.dispatchEvent(new Event('mindos:first-visit'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Only auto-start walkthrough on desktop
|
|
62
|
+
if (window.innerWidth < 768) return;
|
|
63
|
+
|
|
64
|
+
fetch('/api/setup')
|
|
65
|
+
.then(r => r.json())
|
|
66
|
+
.then(data => {
|
|
67
|
+
const gs = data.guideState;
|
|
68
|
+
if (!gs) return;
|
|
69
|
+
// Already dismissed or completed
|
|
70
|
+
if (gs.walkthroughDismissed) return;
|
|
71
|
+
// Check if walkthrough should start
|
|
72
|
+
if (gs.active && !gs.dismissed && gs.walkthroughStep === undefined) {
|
|
73
|
+
// First time — only start if ?welcome=1 was present
|
|
74
|
+
if (isWelcome) {
|
|
75
|
+
setStatus('active');
|
|
76
|
+
setCurrentStep(0);
|
|
77
|
+
}
|
|
78
|
+
} else if (typeof gs.walkthroughStep === 'number' && gs.walkthroughStep >= 0 && gs.walkthroughStep < totalSteps && !gs.walkthroughDismissed) {
|
|
79
|
+
// Resume walkthrough
|
|
80
|
+
setStatus('active');
|
|
81
|
+
setCurrentStep(gs.walkthroughStep);
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
.catch(() => {});
|
|
85
|
+
}, [totalSteps]);
|
|
86
|
+
|
|
87
|
+
const start = useCallback(() => {
|
|
88
|
+
setCurrentStep(0);
|
|
89
|
+
setStatus('active');
|
|
90
|
+
persistStep(0, false);
|
|
91
|
+
}, [persistStep]);
|
|
92
|
+
|
|
93
|
+
const next = useCallback(() => {
|
|
94
|
+
const nextStep = currentStep + 1;
|
|
95
|
+
if (nextStep >= totalSteps) {
|
|
96
|
+
setStatus('completed');
|
|
97
|
+
persistStep(totalSteps, false);
|
|
98
|
+
} else {
|
|
99
|
+
setCurrentStep(nextStep);
|
|
100
|
+
persistStep(nextStep, false);
|
|
101
|
+
}
|
|
102
|
+
}, [currentStep, totalSteps, persistStep]);
|
|
103
|
+
|
|
104
|
+
const back = useCallback(() => {
|
|
105
|
+
if (currentStep > 0) {
|
|
106
|
+
const prevStep = currentStep - 1;
|
|
107
|
+
setCurrentStep(prevStep);
|
|
108
|
+
persistStep(prevStep, false);
|
|
109
|
+
}
|
|
110
|
+
}, [currentStep, persistStep]);
|
|
111
|
+
|
|
112
|
+
const skip = useCallback(() => {
|
|
113
|
+
setStatus('dismissed');
|
|
114
|
+
persistStep(currentStep, true);
|
|
115
|
+
}, [currentStep, persistStep]);
|
|
116
|
+
|
|
117
|
+
const value: WalkthroughContextValue = {
|
|
118
|
+
status,
|
|
119
|
+
currentStep,
|
|
120
|
+
totalSteps,
|
|
121
|
+
start,
|
|
122
|
+
next,
|
|
123
|
+
back,
|
|
124
|
+
skip,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<WalkthroughContext.Provider value={value}>
|
|
129
|
+
{children}
|
|
130
|
+
{status === 'active' && <WalkthroughOverlay />}
|
|
131
|
+
</WalkthroughContext.Provider>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
5
|
+
import { useWalkthrough } from './WalkthroughProvider';
|
|
6
|
+
|
|
7
|
+
interface WalkthroughTooltipProps {
|
|
8
|
+
stepIndex: number;
|
|
9
|
+
rect: DOMRect;
|
|
10
|
+
position: 'right' | 'bottom';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function WalkthroughTooltip({ stepIndex, rect, position }: WalkthroughTooltipProps) {
|
|
14
|
+
const wt = useWalkthrough();
|
|
15
|
+
const { t } = useLocale();
|
|
16
|
+
|
|
17
|
+
// Track viewport dimensions in state to avoid stale values and SSR hazard
|
|
18
|
+
const [viewport, setViewport] = useState({ width: 1024, height: 768 });
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const update = () => setViewport({ width: window.innerWidth, height: window.innerHeight });
|
|
21
|
+
update();
|
|
22
|
+
window.addEventListener('resize', update);
|
|
23
|
+
return () => window.removeEventListener('resize', update);
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
if (!wt) return null;
|
|
27
|
+
|
|
28
|
+
const stepData = t.walkthrough.steps[stepIndex] as { title: string; body: string } | undefined;
|
|
29
|
+
if (!stepData) return null;
|
|
30
|
+
|
|
31
|
+
// Calculate tooltip position
|
|
32
|
+
const GAP = 12;
|
|
33
|
+
let top: number;
|
|
34
|
+
let left: number;
|
|
35
|
+
|
|
36
|
+
if (position === 'right') {
|
|
37
|
+
top = rect.top + rect.height / 2;
|
|
38
|
+
left = rect.right + GAP;
|
|
39
|
+
} else {
|
|
40
|
+
top = rect.bottom + GAP;
|
|
41
|
+
left = rect.left + rect.width / 2;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Clamp to viewport
|
|
45
|
+
const maxLeft = viewport.width - 320;
|
|
46
|
+
const maxTop = viewport.height - 200;
|
|
47
|
+
left = Math.min(left, maxLeft);
|
|
48
|
+
top = Math.min(top, maxTop);
|
|
49
|
+
top = Math.max(top, 8);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className="fixed z-[102] w-[280px] rounded-xl border shadow-lg animate-in fade-in slide-in-from-left-2 duration-200"
|
|
54
|
+
style={{
|
|
55
|
+
top: `${top}px`,
|
|
56
|
+
left: `${left}px`,
|
|
57
|
+
transform: position === 'right' ? 'translateY(-50%)' : 'translateX(-50%)',
|
|
58
|
+
background: 'var(--card)',
|
|
59
|
+
borderColor: 'var(--amber)',
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
<div className="p-4">
|
|
63
|
+
{/* Step indicator */}
|
|
64
|
+
<div className="flex items-center gap-2 mb-2">
|
|
65
|
+
<span
|
|
66
|
+
className="text-2xs font-mono px-1.5 py-0.5 rounded-full"
|
|
67
|
+
style={{ background: 'var(--amber-dim)', color: 'var(--amber)' }}
|
|
68
|
+
>
|
|
69
|
+
{t.walkthrough.step(stepIndex + 1, wt.totalSteps)}
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{/* Content */}
|
|
74
|
+
<h3
|
|
75
|
+
className="text-sm font-semibold font-display mb-1"
|
|
76
|
+
style={{ color: 'var(--foreground)' }}
|
|
77
|
+
>
|
|
78
|
+
{stepData.title}
|
|
79
|
+
</h3>
|
|
80
|
+
<p className="text-xs leading-relaxed" style={{ color: 'var(--muted-foreground)' }}>
|
|
81
|
+
{stepData.body}
|
|
82
|
+
</p>
|
|
83
|
+
|
|
84
|
+
{/* Actions */}
|
|
85
|
+
<div className="flex items-center justify-between mt-4 pt-3" style={{ borderTop: '1px solid var(--border)' }}>
|
|
86
|
+
<button
|
|
87
|
+
onClick={wt.skip}
|
|
88
|
+
className="text-xs transition-colors hover:opacity-80"
|
|
89
|
+
style={{ color: 'var(--muted-foreground)' }}
|
|
90
|
+
>
|
|
91
|
+
{t.walkthrough.skip}
|
|
92
|
+
</button>
|
|
93
|
+
<div className="flex items-center gap-2">
|
|
94
|
+
{stepIndex > 0 && (
|
|
95
|
+
<button
|
|
96
|
+
onClick={wt.back}
|
|
97
|
+
className="text-xs px-2.5 py-1 rounded-lg transition-colors hover:bg-muted"
|
|
98
|
+
style={{ color: 'var(--muted-foreground)' }}
|
|
99
|
+
>
|
|
100
|
+
{t.walkthrough.back}
|
|
101
|
+
</button>
|
|
102
|
+
)}
|
|
103
|
+
<button
|
|
104
|
+
onClick={wt.next}
|
|
105
|
+
className="text-xs px-3 py-1.5 rounded-lg font-medium transition-all hover:opacity-90"
|
|
106
|
+
style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
|
|
107
|
+
>
|
|
108
|
+
{stepIndex === wt.totalSteps - 1 ? t.walkthrough.done : t.walkthrough.next}
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Progress dots */}
|
|
115
|
+
<div className="flex items-center justify-center gap-1.5 pb-3">
|
|
116
|
+
{Array.from({ length: wt.totalSteps }, (_, i) => (
|
|
117
|
+
<div
|
|
118
|
+
key={i}
|
|
119
|
+
className="w-1.5 h-1.5 rounded-full transition-all duration-200"
|
|
120
|
+
style={{
|
|
121
|
+
background: i === stepIndex ? 'var(--amber)' : 'var(--border)',
|
|
122
|
+
transform: i === stepIndex ? 'scale(1.3)' : undefined,
|
|
123
|
+
}}
|
|
124
|
+
/>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|