@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.
Files changed (32) hide show
  1. package/app/app/api/update/route.ts +41 -0
  2. package/app/app/explore/page.tsx +12 -0
  3. package/app/components/ActivityBar.tsx +14 -7
  4. package/app/components/GuideCard.tsx +21 -7
  5. package/app/components/HomeContent.tsx +12 -1
  6. package/app/components/KeyboardShortcuts.tsx +102 -0
  7. package/app/components/Panel.tsx +12 -7
  8. package/app/components/SidebarLayout.tsx +18 -1
  9. package/app/components/UpdateBanner.tsx +19 -21
  10. package/app/components/explore/ExploreContent.tsx +100 -0
  11. package/app/components/explore/UseCaseCard.tsx +50 -0
  12. package/app/components/explore/use-cases.ts +30 -0
  13. package/app/components/panels/AgentsPanel.tsx +86 -95
  14. package/app/components/panels/PluginsPanel.tsx +9 -6
  15. package/app/components/settings/AiTab.tsx +5 -3
  16. package/app/components/settings/McpServerStatus.tsx +12 -6
  17. package/app/components/settings/SettingsContent.tsx +5 -2
  18. package/app/components/settings/UpdateTab.tsx +195 -0
  19. package/app/components/settings/types.ts +1 -1
  20. package/app/components/walkthrough/WalkthroughOverlay.tsx +224 -0
  21. package/app/components/walkthrough/WalkthroughProvider.tsx +133 -0
  22. package/app/components/walkthrough/WalkthroughTooltip.tsx +129 -0
  23. package/app/components/walkthrough/index.ts +3 -0
  24. package/app/components/walkthrough/steps.ts +21 -0
  25. package/app/lib/i18n-en.ts +168 -8
  26. package/app/lib/i18n-zh.ts +167 -7
  27. package/app/lib/settings.ts +4 -0
  28. package/app/next.config.ts +1 -1
  29. package/app/package.json +1 -0
  30. package/bin/lib/mcp-spawn.js +13 -2
  31. package/mcp/src/index.ts +3 -2
  32. 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
+ }
@@ -0,0 +1,3 @@
1
+ export { default as WalkthroughProvider, useWalkthrough } from './WalkthroughProvider';
2
+ export { walkthroughSteps } from './steps';
3
+ export type { WalkthroughAnchor } from './steps';