feat(lessons): add lessons from client db
This commit is contained in:
163
src/components/lessons/ClauseBreakdownWidget.tsx
Normal file
163
src/components/lessons/ClauseBreakdownWidget.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import React, { useState } from 'react';
|
||||
import { MousePointerClick } from 'lucide-react';
|
||||
|
||||
export type SegmentType = 'ic' | 'dc' | 'modifier' | 'conjunction' | 'punct' | 'subject' | 'verb';
|
||||
|
||||
export interface Segment {
|
||||
text: string;
|
||||
type: SegmentType;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ClauseExample {
|
||||
title: string;
|
||||
segments: Segment[];
|
||||
}
|
||||
|
||||
interface ClauseBreakdownWidgetProps {
|
||||
examples: ClauseExample[];
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
const TYPE_STYLES: Record<SegmentType, { bg: string; text: string; border: string; ring: string }> = {
|
||||
ic: { bg: 'bg-blue-100', text: 'text-blue-800', border: 'border-blue-300', ring: '#93c5fd' },
|
||||
dc: { bg: 'bg-green-100', text: 'text-green-800', border: 'border-green-300', ring: '#86efac' },
|
||||
modifier: { bg: 'bg-orange-100', text: 'text-orange-800', border: 'border-orange-300', ring: '#fdba74' },
|
||||
conjunction: { bg: 'bg-purple-100', text: 'text-purple-800', border: 'border-purple-300', ring: '#c4b5fd' },
|
||||
subject: { bg: 'bg-sky-100', text: 'text-sky-800', border: 'border-sky-300', ring: '#7dd3fc' },
|
||||
verb: { bg: 'bg-rose-100', text: 'text-rose-800', border: 'border-rose-300', ring: '#fda4af' },
|
||||
punct: { bg: 'bg-gray-100', text: 'text-gray-600', border: 'border-gray-300', ring: '#d1d5db' },
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<SegmentType, string> = {
|
||||
ic: 'Independent Clause',
|
||||
dc: 'Dependent Clause',
|
||||
modifier: 'Modifier',
|
||||
conjunction: 'Conjunction',
|
||||
subject: 'Subject',
|
||||
verb: 'Verb / Predicate',
|
||||
punct: 'Punctuation',
|
||||
};
|
||||
|
||||
// Pre-resolved tab accent classes (avoids Tailwind purge issues with dynamic strings)
|
||||
const TAB_ACTIVE: Record<string, string> = {
|
||||
purple: 'border-b-2 border-purple-600 text-purple-700 bg-white',
|
||||
teal: 'border-b-2 border-teal-600 text-teal-700 bg-white',
|
||||
fuchsia: 'border-b-2 border-fuchsia-600 text-fuchsia-700 bg-white',
|
||||
amber: 'border-b-2 border-amber-600 text-amber-700 bg-white',
|
||||
};
|
||||
|
||||
export default function ClauseBreakdownWidget({ examples, accentColor = 'purple' }: ClauseBreakdownWidgetProps) {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
|
||||
const example = examples[activeTab];
|
||||
const switchTab = (i: number) => { setActiveTab(i); setSelected(null); };
|
||||
|
||||
const selectedSeg = selected !== null ? example.segments[selected] : null;
|
||||
const tabActive = TAB_ACTIVE[accentColor] ?? TAB_ACTIVE.purple;
|
||||
|
||||
// Unique labeled segment types for the legend
|
||||
const legendTypes = Array.from(
|
||||
new Set(example.segments.filter(s => s.label).map(s => s.type))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
|
||||
|
||||
{/* Tab strip */}
|
||||
{examples.length > 1 && (
|
||||
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto">
|
||||
{examples.map((ex, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => switchTab(i)}
|
||||
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
i === activeTab ? tabActive : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{ex.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{examples.length === 1 && (
|
||||
<div className="px-5 pt-4 pb-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400">{example.title}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instruction */}
|
||||
<div className="px-5 pt-3 pb-1 flex items-center gap-1.5">
|
||||
<MousePointerClick className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<p className="text-xs text-gray-400 italic">Click any colored part to see its grammatical role</p>
|
||||
</div>
|
||||
|
||||
{/* Sentence display */}
|
||||
<div className="px-5 pt-2 pb-3">
|
||||
<div className="text-base leading-10 bg-gray-50 rounded-xl border border-gray-200 px-5 py-4 select-none">
|
||||
{example.segments.map((seg, i) => {
|
||||
if (!seg.label) {
|
||||
// Punctuation / unlabeled — plain unstyled text, not clickable
|
||||
return <span key={i} className="text-gray-700">{seg.text}</span>;
|
||||
}
|
||||
const style = TYPE_STYLES[seg.type];
|
||||
const isSelected = selected === i;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
onClick={() => setSelected(isSelected ? null : i)}
|
||||
className={`inline cursor-pointer rounded px-1 py-0.5 mx-0.5 transition-all ${style.bg} ${style.text} ${
|
||||
isSelected
|
||||
? `border-2 ${style.border} font-semibold`
|
||||
: `border ${style.border} hover:opacity-80`
|
||||
}`}
|
||||
style={isSelected ? { outline: `2.5px solid ${style.ring}`, outlineOffset: '1px' } : {}}
|
||||
>
|
||||
{seg.text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Selection indicator */}
|
||||
{selectedSeg ? (
|
||||
<div
|
||||
className={`mt-3 rounded-xl border-2 px-4 py-3 flex items-start gap-3 ${TYPE_STYLES[selectedSeg.type].bg} ${TYPE_STYLES[selectedSeg.type].border}`}
|
||||
>
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full mt-1.5 shrink-0"
|
||||
style={{ backgroundColor: TYPE_STYLES[selectedSeg.type].ring }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-xs font-bold uppercase tracking-wider mb-0.5 ${TYPE_STYLES[selectedSeg.type].text}`}>
|
||||
{selectedSeg.label ?? TYPE_LABELS[selectedSeg.type]}
|
||||
</p>
|
||||
<p className={`text-sm font-semibold leading-snug ${TYPE_STYLES[selectedSeg.type].text}`}>
|
||||
"{selectedSeg.text.trim()}"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-gray-400 italic px-1">No element selected — click a colored span above.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="px-5 py-3 border-t border-gray-100 bg-gray-50 flex flex-wrap gap-2">
|
||||
{legendTypes.map(type => {
|
||||
const style = TYPE_STYLES[type];
|
||||
return (
|
||||
<span
|
||||
key={type}
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border ${style.bg} ${style.text} ${style.border}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: TYPE_STYLES[type].ring }} />
|
||||
{TYPE_LABELS[type]}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user