spine-framework-cortex 0.1.1
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/components/CortexSidebar.tsx +130 -0
- package/functions/custom_anonymous-sessions.ts +356 -0
- package/functions/custom_case_analysis.ts +507 -0
- package/functions/custom_community-escalation.ts +234 -0
- package/functions/custom_cortex-chunks.ts +52 -0
- package/functions/custom_cortex-handler.ts +35 -0
- package/functions/custom_funnel-scoring.ts +256 -0
- package/functions/custom_funnel-signal.ts +678 -0
- package/functions/custom_funnel-timers.ts +449 -0
- package/functions/custom_kb-chunker-test.ts +364 -0
- package/functions/custom_kb-chunker.ts +576 -0
- package/functions/custom_kb-embeddings.ts +481 -0
- package/functions/custom_kb-ingestion.ts +448 -0
- package/functions/custom_support-triage.ts +649 -0
- package/functions/custom_tag_management.ts +314 -0
- package/index.tsx +103 -0
- package/manifest.json +82 -0
- package/package.json +29 -0
- package/pages/CortexDashboard.tsx +97 -0
- package/pages/community/CommunityPage.tsx +159 -0
- package/pages/courses/CoursesPage.tsx +231 -0
- package/pages/crm/AccountDetailPage.tsx +393 -0
- package/pages/crm/AccountsPage.tsx +164 -0
- package/pages/crm/ActivityPage.tsx +82 -0
- package/pages/crm/ContactDetailPage.tsx +184 -0
- package/pages/crm/ContactsPage.tsx +87 -0
- package/pages/crm/DealDetailPage.tsx +191 -0
- package/pages/crm/DealsPage.tsx +169 -0
- package/pages/crm/HealthPage.tsx +109 -0
- package/pages/intelligence/IntelligencePage.tsx +314 -0
- package/pages/kb/KBEditorPage.tsx +328 -0
- package/pages/kb/KBIngestionPage.tsx +409 -0
- package/pages/kb/KBPage.tsx +258 -0
- package/pages/support/RedactionReview.tsx +562 -0
- package/pages/support/SupportPage.tsx +395 -0
- package/pages/support/TicketDetailPage.tsx +919 -0
- package/seed/accounts.json +9 -0
- package/seed/link-types.json +44 -0
- package/seed/triggers.json +80 -0
- package/seed/types.json +352 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { Send, Hash, MessageSquarePlus, AlertCircle } from 'lucide-react'
|
|
3
|
+
import { apiFetch } from '@core/lib/api'
|
|
4
|
+
import { Button } from '@core/components/ui/button'
|
|
5
|
+
import { Input } from '@core/components/ui/input'
|
|
6
|
+
import { Badge } from '@core/components/ui/badge'
|
|
7
|
+
import { Skeleton } from '@core/components/ui/skeleton'
|
|
8
|
+
import { ScrollArea } from '@core/components/ui/scroll-area'
|
|
9
|
+
|
|
10
|
+
const CHANNELS = ['general', 'announcements', 'help', 'show-and-tell']
|
|
11
|
+
const CHANNEL_LABELS: Record<string, string> = { general: 'General', announcements: 'Announcements', help: 'Help', 'show-and-tell': 'Show & Tell' }
|
|
12
|
+
|
|
13
|
+
interface Post { id: string; title: string; description?: string; data?: Record<string, any>; created_at: string }
|
|
14
|
+
interface Message { id: string; content: string; direction: string; created_at: string }
|
|
15
|
+
interface Thread { id: string }
|
|
16
|
+
|
|
17
|
+
function ThreadPane({ post }: { post: Post }) {
|
|
18
|
+
const [messages, setMessages] = useState<Message[]>([])
|
|
19
|
+
const [thread, setThread] = useState<Thread | null>(null)
|
|
20
|
+
const [loading, setLoading] = useState(true)
|
|
21
|
+
const [replyText, setReplyText] = useState('')
|
|
22
|
+
const [sending, setSending] = useState(false)
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
setLoading(true)
|
|
26
|
+
apiFetch(`/api/admin-data?action=list&entity=threads&target_id=${post.id}&limit=5`)
|
|
27
|
+
.then(r => r.json()).then(j => {
|
|
28
|
+
const t = (j?.data ?? j)?.[0] ?? null
|
|
29
|
+
setThread(t)
|
|
30
|
+
if (t) return apiFetch(`/api/admin-data?action=list&entity=messages&thread_id=${t.id}&limit=100`).then(r => r.json())
|
|
31
|
+
return []
|
|
32
|
+
}).then(raw => { const msgs = raw?.data ?? raw; setMessages(Array.isArray(msgs) ? msgs : []) }).catch(() => {}).finally(() => setLoading(false))
|
|
33
|
+
}, [post.id])
|
|
34
|
+
|
|
35
|
+
const handleSend = async () => {
|
|
36
|
+
if (!replyText.trim() || !thread) return
|
|
37
|
+
setSending(true)
|
|
38
|
+
try {
|
|
39
|
+
const res = await apiFetch('/api/admin-data?action=create&entity=messages', {
|
|
40
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
body: JSON.stringify({ thread_id: thread.id, content: replyText.trim(), direction: 'outbound', entity: 'messages', type_slug: 'message' }),
|
|
42
|
+
})
|
|
43
|
+
const msg = await res.json()
|
|
44
|
+
if (msg?.id) setMessages(prev => [...prev, msg])
|
|
45
|
+
setReplyText('')
|
|
46
|
+
} finally { setSending(false) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex flex-col h-full">
|
|
51
|
+
<div className="px-6 py-3 border-b border-border shrink-0">
|
|
52
|
+
<h3 className="text-sm font-semibold">{post.title}</h3>
|
|
53
|
+
{post.description && <p className="text-sm text-muted-foreground mt-1">{post.description}</p>}
|
|
54
|
+
</div>
|
|
55
|
+
<ScrollArea className="flex-1 p-4">
|
|
56
|
+
<div className="space-y-3">
|
|
57
|
+
{loading && <Skeleton className="h-10 w-2/3" />}
|
|
58
|
+
{!loading && messages.length === 0 && <p className="text-sm text-muted-foreground italic text-center py-8">No replies yet.</p>}
|
|
59
|
+
{messages.map(msg => (
|
|
60
|
+
<div key={msg.id} className={`flex ${msg.direction === 'outbound' ? 'justify-end' : 'justify-start'}`}>
|
|
61
|
+
<div className={`max-w-sm rounded-lg px-3 py-2 text-sm ${msg.direction === 'outbound' ? 'bg-primary text-primary-foreground' : 'bg-muted border border-border'}`}>{msg.content}</div>
|
|
62
|
+
</div>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
</ScrollArea>
|
|
66
|
+
<div className="border-t p-3 flex gap-2 shrink-0">
|
|
67
|
+
<Input placeholder="Reply as agent…" value={replyText} onChange={e => setReplyText(e.target.value)}
|
|
68
|
+
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()} className="flex-1" />
|
|
69
|
+
<Button size="icon" onClick={handleSend} disabled={sending || !replyText.trim() || !thread}><Send size={14} /></Button>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default function CommunityPage() {
|
|
76
|
+
const [activeChannel, setActiveChannel] = useState('general')
|
|
77
|
+
const [selected, setSelected] = useState<Post | null>(null)
|
|
78
|
+
const [search, setSearch] = useState('')
|
|
79
|
+
const [posts, setPosts] = useState<Post[]>([])
|
|
80
|
+
const [loading, setLoading] = useState(true)
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
apiFetch('/api/admin-data?action=list&entity=items&type_slug=community_post&limit=500')
|
|
84
|
+
.then(r => r.json()).then(j => setPosts(Array.isArray(j?.data) ? j.data : Array.isArray(j) ? j : [])).catch(() => setPosts([])).finally(() => setLoading(false))
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
const channelPosts = posts.filter(p => (p.data?.channel as string || 'general') === activeChannel)
|
|
88
|
+
const filtered = channelPosts.filter(p => p.title?.toLowerCase().includes(search.toLowerCase()))
|
|
89
|
+
const unanswered = posts.filter(p => !p.data?.reply_count)
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className="flex h-full min-h-0">
|
|
93
|
+
<div className="w-44 shrink-0 border-r border-border bg-muted/30 flex flex-col min-h-0">
|
|
94
|
+
<div className="px-3 py-2 h-9 border-b border-border shrink-0 flex items-center">
|
|
95
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Channels</p>
|
|
96
|
+
</div>
|
|
97
|
+
<div className="flex-1 overflow-y-auto py-1">
|
|
98
|
+
{CHANNELS.map(ch => {
|
|
99
|
+
const count = posts.filter(p => (p.data?.channel as string || 'general') === ch).length
|
|
100
|
+
return (
|
|
101
|
+
<button key={ch} onClick={() => { setActiveChannel(ch); setSelected(null) }}
|
|
102
|
+
className={`w-full text-left px-3 py-2 flex items-center gap-2 hover:bg-accent/50 transition-colors ${activeChannel === ch ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground'}`}>
|
|
103
|
+
<Hash size={13} className="shrink-0" />
|
|
104
|
+
<span className="text-sm flex-1 truncate">{CHANNEL_LABELS[ch]}</span>
|
|
105
|
+
{count > 0 && <Badge variant="secondary" className="text-xs h-4 px-1.5">{count}</Badge>}
|
|
106
|
+
</button>
|
|
107
|
+
)
|
|
108
|
+
})}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div className="w-64 shrink-0 border-r border-border flex flex-col min-h-0">
|
|
113
|
+
<div className="flex items-center px-3 py-2 h-9 border-b border-border shrink-0">
|
|
114
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex-1">#{CHANNEL_LABELS[activeChannel]}</p>
|
|
115
|
+
</div>
|
|
116
|
+
<div className="px-3 py-2 border-b border-border shrink-0">
|
|
117
|
+
<Input placeholder="Search…" value={search} onChange={e => setSearch(e.target.value)} className="h-7 text-xs" />
|
|
118
|
+
</div>
|
|
119
|
+
<div className="flex-1 overflow-y-auto">
|
|
120
|
+
{loading ? <div className="p-3 space-y-2">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-12 w-full" />)}</div>
|
|
121
|
+
: filtered.length === 0 ? <div className="flex flex-col items-center justify-center gap-2 py-10 px-4 text-center"><MessageSquarePlus size={24} className="text-muted-foreground/40" /><p className="text-sm text-muted-foreground">No discussions yet.</p></div>
|
|
122
|
+
: filtered.map(post => (
|
|
123
|
+
<button key={post.id} onClick={() => setSelected(post)}
|
|
124
|
+
className={`w-full text-left px-3 py-2.5 border-b border-border hover:bg-accent/50 transition-colors ${selected?.id === post.id ? 'bg-accent border-l-2 border-l-primary' : ''}`}>
|
|
125
|
+
<p className="text-sm font-medium truncate">{post.title || 'Untitled'}</p>
|
|
126
|
+
{post.description && <p className="text-xs text-muted-foreground truncate mt-0.5">{post.description}</p>}
|
|
127
|
+
</button>
|
|
128
|
+
))
|
|
129
|
+
}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
134
|
+
{!selected ? <div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground"><MessageSquarePlus size={32} className="opacity-30" /><p className="text-sm">Select a discussion to reply</p></div>
|
|
135
|
+
: <ThreadPane post={selected} />}
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div className="w-52 shrink-0 border-l border-border flex flex-col min-h-0">
|
|
139
|
+
<div className="px-4 py-2 border-b border-border shrink-0 flex items-center gap-2">
|
|
140
|
+
<AlertCircle className="h-3.5 w-3.5 text-amber-500" />
|
|
141
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Moderation</p>
|
|
142
|
+
</div>
|
|
143
|
+
<ScrollArea className="flex-1">
|
|
144
|
+
<div className="p-3 space-y-1">
|
|
145
|
+
<p className="text-xs font-medium text-muted-foreground px-2 py-1">Unanswered ({unanswered.length})</p>
|
|
146
|
+
{unanswered.length === 0 && <p className="text-xs text-muted-foreground px-2 italic">All caught up.</p>}
|
|
147
|
+
{unanswered.slice(0, 15).map(p => (
|
|
148
|
+
<button key={p.id} onClick={() => { setActiveChannel(p.data?.channel as string || 'general'); setSelected(p) }}
|
|
149
|
+
className="w-full text-left px-2 py-2 rounded hover:bg-accent/50 transition-colors">
|
|
150
|
+
<p className="text-xs font-medium truncate">{p.title || 'Untitled'}</p>
|
|
151
|
+
<p className="text-xs text-muted-foreground">{new Date(p.created_at).toLocaleDateString()}</p>
|
|
152
|
+
</button>
|
|
153
|
+
))}
|
|
154
|
+
</div>
|
|
155
|
+
</ScrollArea>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { resolveTypeId } from '../../../lib/resolveTypeId'
|
|
3
|
+
import { useNavigate } from 'react-router-dom'
|
|
4
|
+
import { apiFetch } from '@core/lib/api'
|
|
5
|
+
import { Button } from '@core/components/ui/button'
|
|
6
|
+
import { Input } from '@core/components/ui/input'
|
|
7
|
+
import { Badge } from '@core/components/ui/badge'
|
|
8
|
+
import { Skeleton } from '@core/components/ui/skeleton'
|
|
9
|
+
import { ScrollArea } from '@core/components/ui/scroll-area'
|
|
10
|
+
import { Separator } from '@core/components/ui/separator'
|
|
11
|
+
import { GraduationCap, Plus, PlayCircle, Edit, MessageSquare } from 'lucide-react'
|
|
12
|
+
|
|
13
|
+
interface Lesson { id: string; title: string; description?: string; status?: string; data?: Record<string, any>; created_at: string }
|
|
14
|
+
interface Message { id: string; content: string; created_at: string; direction: string }
|
|
15
|
+
interface Thread { id: string }
|
|
16
|
+
|
|
17
|
+
function groupByCourse(lessons: Lesson[]): Map<string, Lesson[]> {
|
|
18
|
+
const map = new Map<string, Lesson[]>()
|
|
19
|
+
for (const l of lessons) {
|
|
20
|
+
const key = (l.data?.course_title as string) || 'General'
|
|
21
|
+
if (!map.has(key)) map.set(key, [])
|
|
22
|
+
map.get(key)!.push(l)
|
|
23
|
+
}
|
|
24
|
+
for (const [k, v] of map) map.set(k, [...v].sort((a, b) => ((a.data?.sequence as number) ?? 9999) - ((b.data?.sequence as number) ?? 9999)))
|
|
25
|
+
return map
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function DiscussionPanel({ lesson }: { lesson: Lesson }) {
|
|
29
|
+
const [messages, setMessages] = useState<Message[]>([])
|
|
30
|
+
const [thread, setThread] = useState<Thread | null>(null)
|
|
31
|
+
const [loading, setLoading] = useState(true)
|
|
32
|
+
const [newMessage, setNewMessage] = useState('')
|
|
33
|
+
const [submitting, setSubmitting] = useState(false)
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
setLoading(true)
|
|
37
|
+
apiFetch(`/api/admin-data?action=list&entity=threads&target_id=${lesson.id}&limit=5`)
|
|
38
|
+
.then(r => r.json()).then(j => {
|
|
39
|
+
const t = (j?.data ?? j)?.[0] ?? null
|
|
40
|
+
setThread(t)
|
|
41
|
+
if (t) return apiFetch(`/api/admin-data?action=list&entity=messages&thread_id=${t.id}&limit=50`).then(r => r.json())
|
|
42
|
+
return []
|
|
43
|
+
}).then(raw => { const msgs = raw?.data ?? raw; setMessages(Array.isArray(msgs) ? msgs : []) }).catch(() => {}).finally(() => setLoading(false))
|
|
44
|
+
}, [lesson.id])
|
|
45
|
+
|
|
46
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
47
|
+
e.preventDefault()
|
|
48
|
+
if (!newMessage.trim() || submitting) return
|
|
49
|
+
|
|
50
|
+
setSubmitting(true)
|
|
51
|
+
try {
|
|
52
|
+
// Create thread if it doesn't exist
|
|
53
|
+
let currentThread = thread
|
|
54
|
+
if (!currentThread) {
|
|
55
|
+
const threadResponse = await apiFetch('/api/admin-data?action=create&entity=threads', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
target_type: 'item',
|
|
60
|
+
target_id: lesson.id,
|
|
61
|
+
title: `Discussion: ${lesson.title}`,
|
|
62
|
+
status: 'active'
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
const threadResult = await threadResponse.json()
|
|
66
|
+
currentThread = threadResult?.data || threadResult
|
|
67
|
+
setThread(currentThread)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Create message
|
|
71
|
+
const messageTypeId = await resolveTypeId('message')
|
|
72
|
+
const messageResponse = await apiFetch('/api/admin-data?action=create&entity=messages', {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'Content-Type': 'application/json' },
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
type_id: messageTypeId,
|
|
77
|
+
thread_id: currentThread.id,
|
|
78
|
+
content: newMessage.trim(),
|
|
79
|
+
direction: 'inbound',
|
|
80
|
+
sequence: messages.length + 1
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
const messageResult = await messageResponse.json()
|
|
84
|
+
const newMsg = messageResult?.data || messageResult
|
|
85
|
+
|
|
86
|
+
// Add to local state
|
|
87
|
+
setMessages(prev => [...prev, newMsg])
|
|
88
|
+
setNewMessage('')
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error('Failed to send message:', error)
|
|
91
|
+
} finally {
|
|
92
|
+
setSubmitting(false)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="shrink-0 border-t border-border" style={{ maxHeight: '300px' }}>
|
|
98
|
+
<div className="px-4 py-2 border-b border-border flex items-center gap-2">
|
|
99
|
+
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
|
|
100
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Discussion ({messages.length})</p>
|
|
101
|
+
</div>
|
|
102
|
+
<ScrollArea style={{ maxHeight: '200px' }}>
|
|
103
|
+
<div className="px-4 py-2 space-y-2">
|
|
104
|
+
{loading && <Skeleton className="h-8 w-full" />}
|
|
105
|
+
{!loading && messages.length === 0 && <p className="text-xs text-muted-foreground italic py-2">No discussion yet.</p>}
|
|
106
|
+
{messages.map(msg => (
|
|
107
|
+
<div key={msg.id || `msg-${Math.random()}`} className="text-xs border border-border rounded p-2 bg-muted/30">
|
|
108
|
+
<p>{msg.content}</p>
|
|
109
|
+
<p className="text-muted-foreground mt-1">{msg.created_at ? new Date(msg.created_at).toLocaleString() : 'Just now'}</p>
|
|
110
|
+
</div>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
</ScrollArea>
|
|
114
|
+
<div className="px-4 py-3 border-t border-border">
|
|
115
|
+
<form onSubmit={handleSubmit} className="flex gap-2">
|
|
116
|
+
<input
|
|
117
|
+
type="text"
|
|
118
|
+
value={newMessage}
|
|
119
|
+
onChange={(e) => setNewMessage(e.target.value)}
|
|
120
|
+
placeholder="Add a comment..."
|
|
121
|
+
className="flex-1 px-3 py-2 text-xs border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
|
122
|
+
disabled={submitting}
|
|
123
|
+
/>
|
|
124
|
+
<button
|
|
125
|
+
type="submit"
|
|
126
|
+
disabled={!newMessage.trim() || submitting}
|
|
127
|
+
className="px-3 py-2 text-xs bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
128
|
+
>
|
|
129
|
+
{submitting ? 'Sending...' : 'Send'}
|
|
130
|
+
</button>
|
|
131
|
+
</form>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export default function CoursesPage() {
|
|
138
|
+
const navigate = useNavigate()
|
|
139
|
+
const [lessons, setLessons] = useState<Lesson[]>([])
|
|
140
|
+
const [loading, setLoading] = useState(true)
|
|
141
|
+
const [search, setSearch] = useState('')
|
|
142
|
+
const [selectedCourse, setSelectedCourse] = useState<string | null>(null)
|
|
143
|
+
const [selected, setSelected] = useState<Lesson | null>(null)
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
apiFetch('/api/admin-data?action=list&entity=items&type_slug=course_lesson&limit=500')
|
|
147
|
+
.then(r => r.json()).then(j => setLessons(Array.isArray(j?.data) ? j.data : Array.isArray(j) ? j : [])).catch(() => setLessons([])).finally(() => setLoading(false))
|
|
148
|
+
}, [])
|
|
149
|
+
|
|
150
|
+
const courseMap = groupByCourse(lessons)
|
|
151
|
+
const courseNames = Array.from(courseMap.keys()).filter(c => !search || c.toLowerCase().includes(search.toLowerCase()))
|
|
152
|
+
const chaptersForCourse = selectedCourse ? courseMap.get(selectedCourse) ?? [] : []
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div className="flex h-full min-h-0">
|
|
156
|
+
<div className="w-48 shrink-0 border-r border-border flex flex-col min-h-0">
|
|
157
|
+
<div className="px-4 py-3 border-b border-border shrink-0 flex items-center justify-between">
|
|
158
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Courses</p>
|
|
159
|
+
</div>
|
|
160
|
+
<div className="px-3 py-2 border-b border-border shrink-0">
|
|
161
|
+
<Input placeholder="Search…" value={search} onChange={e => setSearch(e.target.value)} className="h-7 text-xs" />
|
|
162
|
+
</div>
|
|
163
|
+
<div className="flex-1 overflow-y-auto">
|
|
164
|
+
{loading ? <div className="p-3 space-y-2">{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}</div>
|
|
165
|
+
: courseNames.length === 0 ? <div className="p-4 text-sm text-muted-foreground text-center">No courses.</div>
|
|
166
|
+
: courseNames.map(name => (
|
|
167
|
+
<button key={name} onClick={() => { setSelectedCourse(name); setSelected(null) }}
|
|
168
|
+
className={`w-full text-left px-4 py-3 border-b border-border hover:bg-accent/50 transition-colors ${selectedCourse === name ? 'bg-accent border-l-2 border-l-primary' : ''}`}>
|
|
169
|
+
<p className={`text-sm font-medium truncate ${selectedCourse === name ? 'text-primary' : ''}`}>{name}</p>
|
|
170
|
+
<p className="text-xs text-muted-foreground mt-0.5">{courseMap.get(name)?.length} lessons</p>
|
|
171
|
+
</button>
|
|
172
|
+
))
|
|
173
|
+
}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div className="w-64 shrink-0 border-r border-border flex flex-col min-h-0">
|
|
178
|
+
<div className="px-4 py-3 border-b border-border shrink-0">
|
|
179
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Lessons</p>
|
|
180
|
+
</div>
|
|
181
|
+
<div className="flex-1 overflow-y-auto">
|
|
182
|
+
{!selectedCourse ? <div className="p-4 text-sm text-muted-foreground">Select a course</div>
|
|
183
|
+
: chaptersForCourse.length === 0 ? <div className="p-4 text-sm text-muted-foreground">No lessons.</div>
|
|
184
|
+
: chaptersForCourse.map(lesson => {
|
|
185
|
+
const seq = lesson.data?.sequence as number | undefined
|
|
186
|
+
return (
|
|
187
|
+
<button key={lesson.id} onClick={() => setSelected(lesson)}
|
|
188
|
+
className={`w-full text-left px-4 py-3 border-b border-border hover:bg-accent/50 flex items-center gap-2 transition-colors ${selected?.id === lesson.id ? 'bg-accent border-l-2 border-l-primary' : ''}`}>
|
|
189
|
+
<PlayCircle size={14} className="text-muted-foreground shrink-0" />
|
|
190
|
+
<p className="text-sm truncate">{seq != null ? `${seq}. ` : ''}{lesson.title}</p>
|
|
191
|
+
</button>
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
199
|
+
{!selected ? (
|
|
200
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
|
201
|
+
<GraduationCap size={32} className="opacity-30" />
|
|
202
|
+
<p className="text-sm">{selectedCourse ? 'Select a lesson' : 'Select a course to get started'}</p>
|
|
203
|
+
</div>
|
|
204
|
+
) : (
|
|
205
|
+
<>
|
|
206
|
+
<div className="px-6 py-3 border-b border-border shrink-0 flex items-center justify-between">
|
|
207
|
+
<div>
|
|
208
|
+
<h2 className="text-base font-semibold">{selected.title}</h2>
|
|
209
|
+
{selected.description && <p className="text-xs text-muted-foreground mt-0.5">{selected.description}</p>}
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
<ScrollArea className="flex-1 border-b border-border">
|
|
213
|
+
<div className="px-6 py-5 max-w-2xl space-y-4">
|
|
214
|
+
{selected.data?.video_url && (
|
|
215
|
+
<div className="aspect-video bg-muted rounded-lg flex items-center justify-center border border-border">
|
|
216
|
+
<a href={selected.data.video_url as string} target="_blank" rel="noopener noreferrer" className="flex flex-col items-center gap-2 text-muted-foreground hover:text-foreground">
|
|
217
|
+
<PlayCircle size={40} /><span className="text-sm">Watch Video</span>
|
|
218
|
+
</a>
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
{selected.data?.content && <><Separator /><div className="text-sm leading-relaxed whitespace-pre-wrap">{selected.data.content as string}</div></>}
|
|
222
|
+
{!selected.data?.video_url && !selected.data?.content && <p className="text-sm text-muted-foreground italic">No content available.</p>}
|
|
223
|
+
</div>
|
|
224
|
+
</ScrollArea>
|
|
225
|
+
<DiscussionPanel lesson={selected} />
|
|
226
|
+
</>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
)
|
|
231
|
+
}
|