Fix public access mode, introduce loading spinners, package fixes
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const apiHost = typeof window !== 'undefined' && window.location.hostname ? window.location.hostname : '127.0.0.1';
|
||||
const apiClient = axios.create({
|
||||
baseURL: `http://${apiHost}:8000`,
|
||||
const getApiBaseUrl = () => {
|
||||
if (import.meta.env.VITE_API_BASE_URL) {
|
||||
return import.meta.env.VITE_API_BASE_URL;
|
||||
}
|
||||
// Use relative path in browser so requests are routed via Vite proxy or reverse proxy
|
||||
if (typeof window !== 'undefined') {
|
||||
return '';
|
||||
}
|
||||
return 'http://127.0.0.1:8000';
|
||||
};
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: getApiBaseUrl(),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -86,3 +96,26 @@ export const getHandbookExample = async (
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const generateChallenge = async (
|
||||
prompt: string,
|
||||
language: string,
|
||||
difficulty: string,
|
||||
subject: string
|
||||
) => {
|
||||
const response = await apiClient.post('/challenges/generate', {
|
||||
prompt,
|
||||
language,
|
||||
difficulty,
|
||||
subject,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const saveChallenge = async (filename: string, markdown: string) => {
|
||||
const response = await apiClient.post('/challenges/save', {
|
||||
filename,
|
||||
markdown,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getChallenges, Challenge } from '../api/client';
|
||||
import { getChallenges, generateChallenge, saveChallenge, Challenge } from '../api/client';
|
||||
import { MarkdownView } from '../components/MarkdownView';
|
||||
import { BookOpen, ArrowRight, Code, ShieldCheck, Sparkles, X, Save, Layers } from 'lucide-react';
|
||||
|
||||
@@ -46,23 +46,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onSelectChallenge }) => {
|
||||
setGenStatusMsg('⏳ Generating challenge via LLM backend...');
|
||||
|
||||
try {
|
||||
const apiHost = typeof window !== 'undefined' && window.location.hostname ? window.location.hostname : '127.0.0.1';
|
||||
const response = await fetch(`http://${apiHost}:8000/challenges/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: promptText,
|
||||
language: genLanguage,
|
||||
difficulty: genDifficulty,
|
||||
subject: genSubject,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
|
||||
const res = await response.json();
|
||||
const res = await generateChallenge(promptText, genLanguage, genDifficulty, genSubject);
|
||||
if (res.status === 'success') {
|
||||
setGenMarkdown(res.markdown);
|
||||
setGenFilename(res.filename);
|
||||
@@ -87,22 +71,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onSelectChallenge }) => {
|
||||
setGenStatusMsg('Saving challenge to disk...');
|
||||
|
||||
try {
|
||||
const apiHost = typeof window !== 'undefined' && window.location.hostname ? window.location.hostname : '127.0.0.1';
|
||||
const response = await fetch(`http://${apiHost}:8000/challenges/save`, {
|
||||
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: genFilename,
|
||||
markdown: genMarkdown,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
|
||||
const res = await response.json();
|
||||
const res = await saveChallenge(genFilename, genMarkdown);
|
||||
if (res.status === 'success') {
|
||||
setGenStatusMsg(`✓ Saved challenge to ${res.filename}`);
|
||||
fetchChallengesList(); // Refresh dashboard list
|
||||
|
||||
@@ -51,9 +51,8 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
const [rightWidth, setRightWidth] = useState(350); // Right Sidebar width in px
|
||||
const [outputHeight, setOutputHeight] = useState(180); // Output Console height in px
|
||||
|
||||
const isDraggingLeft = useRef(false);
|
||||
const isDraggingRight = useRef(false);
|
||||
const isDraggingOutput = useRef(false);
|
||||
const editorRef = useRef<any>(null);
|
||||
const [draggingType, setDraggingType] = useState<'left' | 'right' | 'output' | null>(null);
|
||||
|
||||
const [output, setOutput] = useState<{ status: 'idle' | 'success' | 'error'; text: string }>({
|
||||
status: 'idle',
|
||||
@@ -102,11 +101,8 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
getHandbookCatalog(currentChallenge.language)
|
||||
.then((catalog) => {
|
||||
setHandbookCatalog(catalog);
|
||||
if (catalog.functions.length > 0) {
|
||||
setSelectedTopic(catalog.functions[0]);
|
||||
} else if (catalog.subjects.length > 0) {
|
||||
setSelectedTopic(catalog.subjects[0]);
|
||||
}
|
||||
setSelectedTopic(null);
|
||||
setHandbookExample('');
|
||||
})
|
||||
.catch((err) => console.error('Failed to load handbook catalog:', err));
|
||||
}
|
||||
@@ -114,23 +110,27 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
|
||||
// Global mousemove and mouseup listeners for drag-to-resize splitters
|
||||
useEffect(() => {
|
||||
if (!draggingType) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDraggingLeft.current) {
|
||||
const newWidth = Math.max(180, Math.min(600, e.clientX));
|
||||
if (draggingType === 'left') {
|
||||
const newWidth = Math.max(150, Math.min(Math.floor(window.innerWidth * 0.6), e.clientX));
|
||||
setLeftWidth(newWidth);
|
||||
} else if (isDraggingRight.current) {
|
||||
const newWidth = Math.max(220, Math.min(700, window.innerWidth - e.clientX));
|
||||
} else if (draggingType === 'right') {
|
||||
const newWidth = Math.max(180, Math.min(Math.floor(window.innerWidth * 0.6), window.innerWidth - e.clientX));
|
||||
setRightWidth(newWidth);
|
||||
} else if (isDraggingOutput.current) {
|
||||
const newHeight = Math.max(80, Math.min(650, window.innerHeight - e.clientY));
|
||||
} else if (draggingType === 'output') {
|
||||
const maxHeight = Math.max(150, window.innerHeight - 150);
|
||||
const newHeight = Math.max(60, Math.min(maxHeight, window.innerHeight - e.clientY));
|
||||
setOutputHeight(newHeight);
|
||||
if (editorRef.current) {
|
||||
try { editorRef.current.layout(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDraggingLeft.current = false;
|
||||
isDraggingRight.current = false;
|
||||
isDraggingOutput.current = false;
|
||||
setDraggingType(null);
|
||||
document.body.style.cursor = 'default';
|
||||
document.body.style.userSelect = 'auto';
|
||||
};
|
||||
@@ -141,25 +141,31 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
}, [draggingType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current) {
|
||||
try { editorRef.current.layout(); } catch (_) {}
|
||||
}
|
||||
}, [outputHeight, leftWidth, rightWidth]);
|
||||
|
||||
const startDraggingLeft = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingLeft.current = true;
|
||||
setDraggingType('left');
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const startDraggingRight = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingRight.current = true;
|
||||
setDraggingType('right');
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const startDraggingOutput = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingOutput.current = true;
|
||||
setDraggingType('output');
|
||||
document.body.style.cursor = 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
@@ -225,7 +231,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
|
||||
setIsLoadingGuidance(true);
|
||||
setRightSidebarMode('mentor');
|
||||
setGuidance('⏳ **Mentor is thinking and generating guidance...**\n\n*Analyzing your code, task requirements, and question...*');
|
||||
setGuidance('');
|
||||
setUserQuestion('');
|
||||
|
||||
try {
|
||||
@@ -399,10 +405,39 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
|
||||
{/* Main Container */}
|
||||
<div className="flex flex-1 overflow-hidden relative">
|
||||
{/* Full-Screen Drag Overlay to bypass Monaco Editor event capture */}
|
||||
{draggingType && (
|
||||
<div
|
||||
onMouseMove={(e) => {
|
||||
if (draggingType === 'left') {
|
||||
const newWidth = Math.max(150, Math.min(Math.floor(window.innerWidth * 0.6), e.clientX));
|
||||
setLeftWidth(newWidth);
|
||||
} else if (draggingType === 'right') {
|
||||
const newWidth = Math.max(180, Math.min(Math.floor(window.innerWidth * 0.6), window.innerWidth - e.clientX));
|
||||
setRightWidth(newWidth);
|
||||
} else if (draggingType === 'output') {
|
||||
const maxHeight = Math.max(150, window.innerHeight - 150);
|
||||
const newHeight = Math.max(60, Math.min(maxHeight, window.innerHeight - e.clientY));
|
||||
setOutputHeight(newHeight);
|
||||
if (editorRef.current) {
|
||||
try { editorRef.current.layout(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
setDraggingType(null);
|
||||
document.body.style.cursor = 'default';
|
||||
document.body.style.userSelect = 'auto';
|
||||
}}
|
||||
className={`fixed inset-0 z-[99999] select-none ${
|
||||
draggingType === 'output' ? 'cursor-row-resize' : 'cursor-col-resize'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{/* Left Column: Challenge Specifications (Scalable Width) */}
|
||||
<div
|
||||
style={{ width: `${leftWidth}px` }}
|
||||
className="border-r border-darkBorder bg-darkBg flex flex-col overflow-hidden shrink-0"
|
||||
className="border-r border-transparent bg-darkBg flex flex-col overflow-hidden shrink-0"
|
||||
>
|
||||
<div className="p-3 bg-darkSurface border-b border-darkBorder flex justify-between items-center">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-gray-400">Challenge Details</h2>
|
||||
@@ -475,21 +510,21 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
{/* Resizable Splitter Handle: Left Column */}
|
||||
<div
|
||||
onMouseDown={startDraggingLeft}
|
||||
className="w-1.5 bg-darkBorder hover:bg-blue-500 active:bg-blue-600 cursor-col-resize flex items-center justify-center shrink-0 group transition-colors"
|
||||
className="w-2.5 -mx-1 z-10 bg-transparent hover:bg-blue-500/50 active:bg-blue-600 cursor-col-resize flex items-center justify-center shrink-0 group transition-colors select-none"
|
||||
title="Drag to resize Challenge Panel"
|
||||
>
|
||||
<GripVertical size={10} className="text-gray-600 group-hover:text-white" />
|
||||
<GripVertical size={12} className="text-gray-600/40 group-hover:text-white" />
|
||||
</div>
|
||||
|
||||
{/* Middle Column: Monaco Code Editor & Resizable Output Console */}
|
||||
<div className="flex-1 flex flex-col bg-[#1e1e1e] overflow-hidden">
|
||||
<div className="flex-1 flex flex-col bg-[#1e1e1e] overflow-hidden min-w-0 min-h-0">
|
||||
<div className="bg-darkBg text-gray-400 px-4 py-2 text-xs flex justify-between items-center border-b border-darkBorder shrink-0">
|
||||
<span className="font-mono text-blue-400">main.py</span>
|
||||
<span className="text-[11px] text-gray-500">TactiTerm IDE</span>
|
||||
</div>
|
||||
|
||||
{/* Code Editor */}
|
||||
<div className="flex-1 relative">
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage={currentChallenge?.language.toLowerCase() || 'python'}
|
||||
@@ -497,7 +532,10 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
defaultValue=""
|
||||
value={code}
|
||||
theme="vs-dark"
|
||||
onMount={(_, monaco) => registerMonacoCompletions(monaco)}
|
||||
onMount={(editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
registerMonacoCompletions(monaco);
|
||||
}}
|
||||
onChange={(val) => setCode(val || '')}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
@@ -514,16 +552,16 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
{/* Resizable Splitter Handle: Output Console */}
|
||||
<div
|
||||
onMouseDown={startDraggingOutput}
|
||||
className="h-1.5 bg-darkBorder hover:bg-blue-500 active:bg-blue-600 cursor-row-resize flex items-center justify-center shrink-0 group transition-colors"
|
||||
className="h-2.5 -my-1 z-10 bg-transparent hover:bg-blue-500/50 active:bg-blue-600 cursor-row-resize flex items-center justify-center shrink-0 group transition-colors select-none"
|
||||
title="Drag up/down to resize Output Console"
|
||||
>
|
||||
<GripHorizontal size={10} className="text-gray-600 group-hover:text-white" />
|
||||
<GripHorizontal size={12} className="text-gray-600/40 group-hover:text-white" />
|
||||
</div>
|
||||
|
||||
{/* Scalable Output & Stdin Input Terminal Area */}
|
||||
<div
|
||||
style={{ height: `${outputHeight}px` }}
|
||||
className="border-t border-darkBorder bg-black/90 p-4 font-mono text-xs flex flex-col overflow-hidden shrink-0"
|
||||
className="border-t border-transparent bg-black/90 p-4 font-mono text-xs flex flex-col overflow-hidden shrink-0 min-h-0"
|
||||
>
|
||||
<div className="flex justify-between items-center text-gray-400 pb-2 mb-2 border-b border-gray-800 text-[11px] uppercase font-bold shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -566,10 +604,10 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
{rightSidebarMode !== 'closed' && (
|
||||
<div
|
||||
onMouseDown={startDraggingRight}
|
||||
className="w-1.5 bg-darkBorder hover:bg-blue-500 active:bg-blue-600 cursor-col-resize flex items-center justify-center shrink-0 group transition-colors"
|
||||
className="w-2.5 -mx-1 z-10 bg-transparent hover:bg-blue-500/50 active:bg-blue-600 cursor-col-resize flex items-center justify-center shrink-0 group transition-colors select-none"
|
||||
title="Drag to resize Right Sidebar"
|
||||
>
|
||||
<GripVertical size={10} className="text-gray-600 group-hover:text-white" />
|
||||
<GripVertical size={12} className="text-gray-600/40 group-hover:text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -577,7 +615,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
{rightSidebarMode === 'mentor' && (
|
||||
<div
|
||||
style={{ width: `${rightWidth}px` }}
|
||||
className="border-l border-darkBorder bg-darkSurface flex flex-col overflow-hidden shrink-0"
|
||||
className="border-l border-transparent bg-darkSurface flex flex-col overflow-hidden shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-darkBorder bg-darkBg flex justify-between items-center">
|
||||
<h2 className="flex items-center gap-2 font-bold text-amber-400 text-xs uppercase tracking-wider">
|
||||
@@ -593,9 +631,18 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
|
||||
{/* Mentor Guidance View */}
|
||||
<div className="flex-1 p-4 overflow-y-auto space-y-4">
|
||||
<div className="p-4 bg-darkBg border border-darkBorder rounded-xl shadow-inner">
|
||||
<MarkdownView content={guidance} />
|
||||
</div>
|
||||
{isLoadingGuidance ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="w-10 h-10 border-4 border-amber-500/20 border-t-amber-400 rounded-full animate-spin" />
|
||||
<Sparkles size={16} className="absolute text-amber-400 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-darkBg border border-darkBorder rounded-xl shadow-inner">
|
||||
<MarkdownView content={guidance} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Question Input Area */}
|
||||
@@ -616,7 +663,11 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
disabled={isLoadingGuidance}
|
||||
className="flex-1 py-2 bg-amber-600 hover:bg-amber-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center justify-center gap-1.5 shadow-sm"
|
||||
>
|
||||
<Send size={14} />
|
||||
{isLoadingGuidance ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Send size={14} />
|
||||
)}
|
||||
<span>Ask Mentor</span>
|
||||
</button>
|
||||
|
||||
@@ -626,7 +677,11 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
className="py-2 px-3 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center justify-center gap-1"
|
||||
title="Check Answer"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
{isLoadingGuidance ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Sparkles size={14} />
|
||||
)}
|
||||
<span>Check</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -638,7 +693,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
{rightSidebarMode === 'handbook' && (
|
||||
<div
|
||||
style={{ width: `${rightWidth}px` }}
|
||||
className="border-l border-darkBorder bg-darkSurface flex flex-col overflow-hidden shrink-0"
|
||||
className="border-l border-transparent bg-darkSurface flex flex-col overflow-hidden shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-darkBorder bg-darkBg flex justify-between items-center">
|
||||
<h2 className="flex items-center gap-2 font-bold text-indigo-400 text-xs uppercase tracking-wider">
|
||||
@@ -691,8 +746,25 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Topic Catalog List */}
|
||||
<div className="p-3 border-b border-darkBorder bg-darkBg flex flex-col gap-1.5 max-h-48 overflow-y-auto">
|
||||
{/* Topic Catalog List (Fills full height when no topic selected) */}
|
||||
<div className={`p-3 bg-darkBg flex flex-col gap-1.5 overflow-y-auto ${
|
||||
selectedTopic ? 'max-h-52 border-b border-darkBorder' : 'flex-1'
|
||||
}`}>
|
||||
{selectedTopic && (
|
||||
<div className="flex justify-between items-center pb-1 text-[11px] text-gray-400 font-sans border-b border-darkBorder/40 mb-1">
|
||||
<span>Selected: <strong className="text-indigo-400">{selectedTopic.title}</strong></span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedTopic(null);
|
||||
setHandbookExample('');
|
||||
}}
|
||||
className="text-indigo-400 hover:text-indigo-300 underline text-[10px] font-semibold"
|
||||
>
|
||||
Clear selection (Show all)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredList.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 italic p-2">No matching functions or topics found.</p>
|
||||
) : (
|
||||
@@ -700,7 +772,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelectHandbookTopic(item)}
|
||||
className={`text-left p-2 rounded-lg text-xs transition-all border ${
|
||||
className={`text-left p-2.5 rounded-lg text-xs transition-all border ${
|
||||
selectedTopic?.id === item.id
|
||||
? 'bg-indigo-600/20 border-indigo-500/50 text-indigo-300 font-bold'
|
||||
: 'bg-darkSurface border-darkBorder/50 text-gray-300 hover:bg-gray-800'
|
||||
@@ -716,12 +788,23 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generated LLM Reference Card View */}
|
||||
<div className="flex-1 p-4 overflow-y-auto">
|
||||
<div className="p-4 bg-darkBg border border-darkBorder rounded-xl shadow-inner">
|
||||
<MarkdownView content={handbookExample} />
|
||||
{/* Generated LLM Reference Card View (ONLY renders after selecting a topic) */}
|
||||
{selectedTopic && (
|
||||
<div className="flex-1 p-4 overflow-y-auto bg-darkSurface border-t border-darkBorder">
|
||||
<div className="p-4 bg-darkBg border border-darkBorder rounded-xl shadow-inner">
|
||||
{isLoadingExample ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="w-10 h-10 border-4 border-indigo-500/20 border-t-indigo-400 rounded-full animate-spin" />
|
||||
<BookMarked size={16} className="absolute text-indigo-400 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownView content={handbookExample} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user