feat(dashboard): add LearnPanel component and tour store state

Add tour state management (tourActive, currentTourStep, tourHighlightedNodeIds)
to the Zustand store with start/stop/navigate actions. Create LearnPanel
component with three states: no tour, tour preview, and active tour with
step navigation, markdown descriptions, language lesson boxes, and
referenced component pills. Replace bottom-right panel in App.tsx with
tabbed Details/Tour view when a tour is available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-03-14 20:23:03 +08:00
co-authored by Claude Opus 4.6
parent cb3d860fe2
commit f9da9c40cd
3 changed files with 338 additions and 3 deletions
+42 -3
View File
@@ -7,10 +7,12 @@ import SearchBar from "./components/SearchBar";
import NodeInfo from "./components/NodeInfo";
import ChatPanel from "./components/ChatPanel";
import LayerLegend from "./components/LayerLegend";
import LearnPanel from "./components/LearnPanel";
function App() {
const graph = useDashboardStore((s) => s.graph);
const setGraph = useDashboardStore((s) => s.setGraph);
const tourActive = useDashboardStore((s) => s.tourActive);
useEffect(() => {
fetch("/knowledge-graph.json")
@@ -21,6 +23,7 @@ function App() {
const nodeCount = graph?.nodes.length ?? 0;
const edgeCount = graph?.edges.length ?? 0;
const hasTour = (graph?.tour.length ?? 0) > 0;
return (
<div className="h-screen w-screen flex flex-col bg-gray-900 text-white">
@@ -64,9 +67,45 @@ function App() {
<ChatPanel />
</div>
{/* Bottom-right: Node Info */}
<div className="min-h-0 min-w-0">
<NodeInfo />
{/* Bottom-right: Node Info / Learn Panel */}
<div className="min-h-0 min-w-0 flex flex-col">
{hasTour ? (
<>
{/* Tab bar */}
<div className="flex bg-gray-800 rounded-t-lg border-b border-gray-700 shrink-0">
<button
onClick={() => {
if (tourActive) useDashboardStore.getState().stopTour();
}}
className={`flex-1 text-xs font-medium py-1.5 px-3 transition-colors ${
!tourActive
? "text-white border-b-2 border-indigo-500"
: "text-gray-500 hover:text-gray-300"
}`}
>
Details
</button>
<button
onClick={() => {
if (!tourActive) useDashboardStore.getState().startTour();
}}
className={`flex-1 text-xs font-medium py-1.5 px-3 transition-colors ${
tourActive
? "text-white border-b-2 border-indigo-500"
: "text-gray-500 hover:text-gray-300"
}`}
>
Tour
</button>
</div>
{/* Active panel */}
<div className="flex-1 min-h-0">
{tourActive ? <LearnPanel /> : <NodeInfo />}
</div>
</>
) : (
<NodeInfo />
)}
</div>
</div>
</div>
@@ -0,0 +1,226 @@
import ReactMarkdown from "react-markdown";
import { useDashboardStore } from "../store";
export default function LearnPanel() {
const graph = useDashboardStore((s) => s.graph);
const tourActive = useDashboardStore((s) => s.tourActive);
const currentTourStep = useDashboardStore((s) => s.currentTourStep);
const startTour = useDashboardStore((s) => s.startTour);
const stopTour = useDashboardStore((s) => s.stopTour);
const setTourStep = useDashboardStore((s) => s.setTourStep);
const nextTourStep = useDashboardStore((s) => s.nextTourStep);
const prevTourStep = useDashboardStore((s) => s.prevTourStep);
const selectNode = useDashboardStore((s) => s.selectNode);
const tourSteps = graph?.tour
? [...graph.tour].sort((a, b) => a.order - b.order)
: [];
const hasTour = tourSteps.length > 0;
// State 1: No tour available
if (!hasTour) {
return (
<div className="h-full w-full flex items-center justify-center bg-gray-800 rounded-lg">
<div className="text-center px-4">
<div className="text-2xl mb-2 text-gray-600">&#x1f9ed;</div>
<p className="text-gray-400 text-sm">No tour available</p>
<p className="text-gray-500 text-xs mt-1">
Generate a tour from your knowledge graph to get a guided walkthrough
</p>
</div>
</div>
);
}
// State 2: Tour available but not started
if (!tourActive) {
return (
<div className="h-full w-full bg-gray-800 rounded-lg overflow-auto p-4">
<div className="mb-4">
<h2 className="text-lg font-bold text-white mb-1">Project Tour</h2>
<p className="text-xs text-gray-400">
{tourSteps.length} steps &middot; Guided walkthrough of the codebase
</p>
</div>
<button
onClick={startTour}
className="w-full mb-4 bg-indigo-600 text-white text-sm font-medium py-2 px-4 rounded hover:bg-indigo-500 transition-colors"
>
Start Tour
</button>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
Steps
</h3>
{tourSteps.map((step, i) => (
<div
key={step.order}
className="flex items-start gap-2 text-xs bg-gray-700/50 rounded px-3 py-2"
>
<span className="text-gray-500 font-mono shrink-0 mt-0.5">
{i + 1}.
</span>
<span className="text-gray-300">{step.title}</span>
</div>
))}
</div>
</div>
);
}
// State 3: Tour active
const step = tourSteps[currentTourStep];
if (!step) return null;
const totalSteps = tourSteps.length;
const progressPct = ((currentTourStep + 1) / totalSteps) * 100;
const isFirst = currentTourStep === 0;
const isLast = currentTourStep === totalSteps - 1;
return (
<div className="h-full w-full bg-gray-800 rounded-lg flex flex-col overflow-hidden">
{/* Header with progress counter and exit */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-700 shrink-0">
<div className="flex items-center gap-2">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
Tour
</h3>
<span className="text-xs text-gray-500">
{currentTourStep + 1} / {totalSteps}
</span>
</div>
<button
onClick={stopTour}
className="text-[10px] text-gray-500 hover:text-gray-300 transition-colors"
>
Exit Tour
</button>
</div>
{/* Progress bar */}
<div className="h-1 bg-gray-700 shrink-0">
<div
className="h-full bg-indigo-500 transition-all duration-300"
style={{ width: `${progressPct}%` }}
/>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto p-4 min-h-0">
{/* Step title */}
<h2 className="text-lg font-bold text-white mb-3">{step.title}</h2>
{/* Description via ReactMarkdown */}
<div className="text-sm text-gray-300 leading-relaxed mb-4 tour-markdown">
<ReactMarkdown
components={{
p: ({ children }) => (
<p className="mb-1.5 last:mb-0">{children}</p>
),
strong: ({ children }) => (
<strong className="font-semibold text-white">{children}</strong>
),
code: ({ className, children }) => {
const isBlock = className?.includes("language-");
return isBlock ? (
<code className="block bg-gray-900 rounded px-2 py-1.5 mb-1.5 overflow-x-auto text-[11px] leading-relaxed">
{children}
</code>
) : (
<code className="bg-gray-900 rounded px-1 py-0.5 text-[11px]">
{children}
</code>
);
},
ul: ({ children }) => (
<ul className="list-disc list-inside mb-1.5 space-y-0.5">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside mb-1.5 space-y-0.5">
{children}
</ol>
),
}}
>
{step.description}
</ReactMarkdown>
</div>
{/* Language lesson */}
{step.languageLesson && (
<div className="bg-indigo-900/40 border border-indigo-700 rounded p-3 mb-4">
<h4 className="text-xs font-semibold text-indigo-300 uppercase tracking-wider mb-1.5">
Language Lesson
</h4>
<p className="text-sm text-indigo-200 leading-relaxed">
{step.languageLesson}
</p>
</div>
)}
{/* Referenced component pills */}
{step.nodeIds.length > 0 && (
<div className="mb-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
Referenced Components
</h4>
<div className="flex flex-wrap gap-1.5">
{step.nodeIds.map((nodeId) => {
const node = graph?.nodes.find((n) => n.id === nodeId);
return (
<button
key={nodeId}
onClick={() => selectNode(nodeId)}
className="text-[11px] bg-gray-700 text-gray-300 px-2 py-0.5 rounded-full hover:bg-gray-600 hover:text-white transition-colors cursor-pointer"
>
{node?.name ?? nodeId}
</button>
);
})}
</div>
</div>
)}
</div>
{/* Navigation: dots + prev/next */}
<div className="px-3 py-2 border-t border-gray-700 shrink-0">
{/* Step dots */}
<div className="flex justify-center gap-1.5 mb-2">
{tourSteps.map((_, i) => (
<button
key={i}
onClick={() => setTourStep(i)}
className={`w-2 h-2 rounded-full transition-colors ${
i === currentTourStep
? "bg-indigo-500"
: "bg-gray-600 hover:bg-gray-500"
}`}
aria-label={`Go to step ${i + 1}`}
/>
))}
</div>
{/* Prev / Next buttons */}
<div className="flex gap-2">
<button
onClick={prevTourStep}
disabled={isFirst}
className="flex-1 text-xs bg-gray-700 text-gray-300 py-1.5 rounded hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Prev
</button>
<button
onClick={isLast ? stopTour : nextTourStep}
className="flex-1 text-xs bg-indigo-600 text-white py-1.5 rounded hover:bg-indigo-500 transition-colors"
>
{isLast ? "Finish" : "Next"}
</button>
</div>
</div>
</div>
);
}
+70
View File
@@ -22,6 +22,10 @@ interface DashboardStore {
showLayers: boolean;
tourActive: boolean;
currentTourStep: number;
tourHighlightedNodeIds: string[];
setGraph: (graph: KnowledgeGraph) => void;
selectNode: (nodeId: string | null) => void;
setSearchQuery: (query: string) => void;
@@ -29,6 +33,12 @@ interface DashboardStore {
sendChatMessage: (message: string) => Promise<void>;
clearChat: () => void;
toggleLayers: () => void;
startTour: () => void;
stopTour: () => void;
setTourStep: (step: number) => void;
nextTourStep: () => void;
prevTourStep: () => void;
}
function buildSystemPrompt(
@@ -115,6 +125,10 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
showLayers: false,
tourActive: false,
currentTourStep: 0,
tourHighlightedNodeIds: [],
setGraph: (graph) => {
const searchEngine = new SearchEngine(graph.nodes);
const query = get().searchQuery;
@@ -194,4 +208,60 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
clearChat: () => set({ chatMessages: [] }),
toggleLayers: () => set((state) => ({ showLayers: !state.showLayers })),
startTour: () => {
const { graph } = get();
if (!graph || graph.tour.length === 0) return;
const sorted = [...graph.tour].sort((a, b) => a.order - b.order);
set({
tourActive: true,
currentTourStep: 0,
tourHighlightedNodeIds: sorted[0].nodeIds,
selectedNodeId: null,
});
},
stopTour: () =>
set({
tourActive: false,
currentTourStep: 0,
tourHighlightedNodeIds: [],
}),
setTourStep: (step) => {
const { graph } = get();
if (!graph || graph.tour.length === 0) return;
const sorted = [...graph.tour].sort((a, b) => a.order - b.order);
if (step < 0 || step >= sorted.length) return;
set({
currentTourStep: step,
tourHighlightedNodeIds: sorted[step].nodeIds,
});
},
nextTourStep: () => {
const { graph, currentTourStep } = get();
if (!graph || graph.tour.length === 0) return;
const sorted = [...graph.tour].sort((a, b) => a.order - b.order);
if (currentTourStep < sorted.length - 1) {
const next = currentTourStep + 1;
set({
currentTourStep: next,
tourHighlightedNodeIds: sorted[next].nodeIds,
});
}
},
prevTourStep: () => {
const { graph, currentTourStep } = get();
if (!graph || graph.tour.length === 0) return;
if (currentTourStep > 0) {
const sorted = [...graph.tour].sort((a, b) => a.order - b.order);
const prev = currentTourStep - 1;
set({
currentTourStep: prev,
tourHighlightedNodeIds: sorted[prev].nodeIds,
});
}
},
}));