spine-framework-portal 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/CommunityModerator.tsx +104 -0
- package/components/CourseProgress.tsx +169 -0
- package/components/HelpfulnessRating.tsx +59 -0
- package/components/KBGenerator.tsx +159 -0
- package/components/ParticipationIndicator.tsx +23 -0
- package/components/PortalFooter.tsx +32 -0
- package/components/PortalHeader.tsx +149 -0
- package/components/PortalSidebar.tsx +140 -0
- package/components/ProgressTracker.tsx +41 -0
- package/components/SearchFilterBar.tsx +26 -0
- package/components/StatusBadge.tsx +44 -0
- package/components/ThreadPanel.tsx +102 -0
- package/components/UnifiedItemCard.tsx +107 -0
- package/components/VotingComponent.tsx +51 -0
- package/functions/custom_portal-signals.ts +33 -0
- package/index.tsx +47 -0
- package/manifest.json +59 -0
- package/package.json +30 -0
- package/pages/AccountPage.tsx +115 -0
- package/pages/CommunityPage.tsx +208 -0
- package/pages/ContentPage.tsx +269 -0
- package/pages/CoursesPage.tsx +253 -0
- package/pages/HomePage.tsx +137 -0
- package/pages/IntegrityPage.tsx +289 -0
- package/pages/KnowledgePage.tsx +124 -0
- package/pages/MarketplacePage.tsx +250 -0
- package/pages/TicketsPage.tsx +445 -0
- package/seed/link-types.json +1 -0
- package/seed/triggers.json +17 -0
- package/seed/types.json +136 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createHandler } from './_shared/middleware'
|
|
2
|
+
import { processSignal } from './custom_funnel-signal'
|
|
3
|
+
|
|
4
|
+
export const handler = createHandler(async (ctx, body) => {
|
|
5
|
+
const { action_type, action_value, action_description, session_id } = body || {}
|
|
6
|
+
|
|
7
|
+
if (!action_type || typeof action_value !== 'number') {
|
|
8
|
+
const err: any = new Error('action_type and action_value are required')
|
|
9
|
+
err.statusCode = 400
|
|
10
|
+
throw err
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (!ctx.accountId) {
|
|
14
|
+
const err: any = new Error('No account context')
|
|
15
|
+
err.statusCode = 401
|
|
16
|
+
throw err
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const payload = {
|
|
20
|
+
account_id: ctx.accountId,
|
|
21
|
+
person_id: ctx.principal.id,
|
|
22
|
+
session_id: session_id || `portal_${ctx.principal.id}_${Date.now()}`,
|
|
23
|
+
stage: 'identified',
|
|
24
|
+
source: 'int',
|
|
25
|
+
action_type,
|
|
26
|
+
action_value,
|
|
27
|
+
...(action_description && { action_description }),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await processSignal(payload, { accountId: ctx.accountId, requestId: ctx.requestId }, {})
|
|
31
|
+
|
|
32
|
+
return { status: 'ok' }
|
|
33
|
+
})
|
package/index.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { lazy, Suspense } from 'react'
|
|
2
|
+
import { Routes, Route, Navigate } from 'react-router-dom'
|
|
3
|
+
import { LoadingSpinner } from '@core/components/ui/LoadingSpinner'
|
|
4
|
+
import { TooltipProvider } from '@core/components/ui/tooltip'
|
|
5
|
+
import { PortalHeader } from './components/PortalHeader'
|
|
6
|
+
import { PortalFooter } from './components/PortalFooter'
|
|
7
|
+
|
|
8
|
+
const HomePage = lazy(() => import('./pages/HomePage').then(m => ({ default: m.HomePage })))
|
|
9
|
+
const TicketsPage = lazy(() => import('./pages/TicketsPage').then(m => ({ default: m.TicketsPage })))
|
|
10
|
+
const CommunityPage = lazy(() => import('./pages/CommunityPage').then(m => ({ default: m.CommunityPage })))
|
|
11
|
+
const CoursesPage = lazy(() => import('./pages/CoursesPage').then(m => ({ default: m.CoursesPage })))
|
|
12
|
+
const KnowledgePage = lazy(() => import('./pages/KnowledgePage').then(m => ({ default: m.KnowledgePage })))
|
|
13
|
+
const MarketplacePage = lazy(() => import('./pages/MarketplacePage').then(m => ({ default: m.MarketplacePage })))
|
|
14
|
+
|
|
15
|
+
function PortalLayout() {
|
|
16
|
+
return (
|
|
17
|
+
<div className="h-full flex flex-col bg-background overflow-hidden">
|
|
18
|
+
<PortalHeader />
|
|
19
|
+
<main className="flex-1 flex flex-col min-h-0">
|
|
20
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center"><LoadingSpinner /></div>}>
|
|
21
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
22
|
+
<Routes>
|
|
23
|
+
<Route path="/" element={<HomePage />} />
|
|
24
|
+
<Route path="/tickets" element={<TicketsPage />} />
|
|
25
|
+
<Route path="/tickets/:id" element={<TicketsPage />} />
|
|
26
|
+
<Route path="/kb" element={<KnowledgePage />} />
|
|
27
|
+
<Route path="/knowledge" element={<Navigate to="/portal/kb" replace />} />
|
|
28
|
+
<Route path="/courses" element={<CoursesPage />} />
|
|
29
|
+
<Route path="/community" element={<CommunityPage />} />
|
|
30
|
+
<Route path="/marketplace" element={<MarketplacePage />} />
|
|
31
|
+
<Route path="*" element={<Navigate to="/portal" replace />} />
|
|
32
|
+
</Routes>
|
|
33
|
+
</div>
|
|
34
|
+
</Suspense>
|
|
35
|
+
</main>
|
|
36
|
+
<PortalFooter />
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default function CustomerPortalApp() {
|
|
42
|
+
return (
|
|
43
|
+
<TooltipProvider>
|
|
44
|
+
<PortalLayout />
|
|
45
|
+
</TooltipProvider>
|
|
46
|
+
)
|
|
47
|
+
}
|
package/manifest.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Customer Portal",
|
|
3
|
+
"slug": "customer-portal",
|
|
4
|
+
"description": "Self-service portal for customers to access tickets, knowledge base, courses, and community",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"required_roles": ["portal"],
|
|
7
|
+
"routes": [
|
|
8
|
+
"/portal",
|
|
9
|
+
"/portal/tickets",
|
|
10
|
+
"/portal/tickets/:id",
|
|
11
|
+
"/portal/kb",
|
|
12
|
+
"/portal/courses",
|
|
13
|
+
"/portal/community",
|
|
14
|
+
"/portal/marketplace"
|
|
15
|
+
],
|
|
16
|
+
"nav_items": [
|
|
17
|
+
{
|
|
18
|
+
"title": "Home",
|
|
19
|
+
"path": "/portal",
|
|
20
|
+
"icon": "Home",
|
|
21
|
+
"order": 1
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"title": "Tickets",
|
|
25
|
+
"path": "/portal/tickets",
|
|
26
|
+
"icon": "Ticket",
|
|
27
|
+
"order": 2
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"title": "Knowledge Base",
|
|
31
|
+
"path": "/portal/kb",
|
|
32
|
+
"icon": "BookOpen",
|
|
33
|
+
"order": 3
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"title": "Courses",
|
|
37
|
+
"path": "/portal/courses",
|
|
38
|
+
"icon": "GraduationCap",
|
|
39
|
+
"order": 4
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"title": "Community",
|
|
43
|
+
"path": "/portal/community",
|
|
44
|
+
"icon": "Users",
|
|
45
|
+
"order": 5
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"title": "Marketplace",
|
|
49
|
+
"path": "/portal/marketplace",
|
|
50
|
+
"icon": "Store",
|
|
51
|
+
"order": 6
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"features": ["tickets", "kb", "courses", "community", "marketplace"],
|
|
55
|
+
"dependencies": ["items", "threads", "messages"],
|
|
56
|
+
"entry_point": "./index.tsx",
|
|
57
|
+
"is_public": true,
|
|
58
|
+
"auth_required": true
|
|
59
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spine-framework-portal",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Customer Portal — self-service portal app for Spine Framework",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/art-mojo-admin/spine",
|
|
10
|
+
"directory": "custom/apps/customer-portal"
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"spine-framework": ">=0.1.0",
|
|
14
|
+
"spine-framework-cortex": ">=0.1.0"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"index.tsx",
|
|
18
|
+
"manifest.json",
|
|
19
|
+
"seed/",
|
|
20
|
+
"pages/",
|
|
21
|
+
"components/",
|
|
22
|
+
"functions/",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"spine": {
|
|
26
|
+
"type": "app",
|
|
27
|
+
"slug": "customer-portal",
|
|
28
|
+
"manifestPath": "manifest.json"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useAuth } from '@core/contexts/AuthContext'
|
|
2
|
+
import { useTickets } from '../hooks/useTickets'
|
|
3
|
+
import { useCommunityPosts } from '../hooks/useCommunity'
|
|
4
|
+
import { useCourseLessons } from '../hooks/useCourses'
|
|
5
|
+
import { Card, CardContent, CardHeader } from '@core/components/ui/card'
|
|
6
|
+
import { Button } from '@core/components/ui/button'
|
|
7
|
+
import { Badge } from '@core/components/ui/badge'
|
|
8
|
+
import { Separator } from '@core/components/ui/separator'
|
|
9
|
+
import { Skeleton } from '@core/components/ui/skeleton'
|
|
10
|
+
import { Avatar, AvatarFallback } from '@core/components/ui/avatar'
|
|
11
|
+
|
|
12
|
+
export function AccountPage() {
|
|
13
|
+
const { user } = useAuth()
|
|
14
|
+
const { tickets, loading: ticketsLoading } = useTickets()
|
|
15
|
+
const { posts, loading: postsLoading } = useCommunityPosts()
|
|
16
|
+
const { lessons, loading: lessonsLoading } = useCourseLessons()
|
|
17
|
+
|
|
18
|
+
const completedLessons = lessons.filter((l) => l.status === 'completed').length
|
|
19
|
+
const initials = (user?.full_name || user?.email || 'U')
|
|
20
|
+
.split(' ')
|
|
21
|
+
.map((w: string) => w[0])
|
|
22
|
+
.join('')
|
|
23
|
+
.toUpperCase()
|
|
24
|
+
.slice(0, 2)
|
|
25
|
+
|
|
26
|
+
const stats = [
|
|
27
|
+
{ label: 'Support Tickets', value: tickets.length, loading: ticketsLoading },
|
|
28
|
+
{ label: 'Community Posts', value: posts.length, loading: postsLoading },
|
|
29
|
+
{ label: 'Lessons Completed', value: completedLessons, loading: lessonsLoading },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="max-w-3xl mx-auto px-6 py-8 space-y-6">
|
|
34
|
+
<h1 className="text-xl font-semibold tracking-tight">Account Overview</h1>
|
|
35
|
+
|
|
36
|
+
{/* Profile */}
|
|
37
|
+
<Card>
|
|
38
|
+
<CardContent className="py-6 flex items-center gap-5">
|
|
39
|
+
<Avatar className="h-16 w-16">
|
|
40
|
+
<AvatarFallback className="text-xl bg-primary text-primary-foreground">{initials}</AvatarFallback>
|
|
41
|
+
</Avatar>
|
|
42
|
+
<div className="flex-1">
|
|
43
|
+
<p className="font-semibold text-base">{user?.full_name || '—'}</p>
|
|
44
|
+
<p className="text-sm text-muted-foreground">{user?.email}</p>
|
|
45
|
+
<div className="flex items-center gap-2 mt-2">
|
|
46
|
+
<Badge variant="secondary" className="capitalize">{user?.roles?.[0] || 'member'}</Badge>
|
|
47
|
+
{user?.account?.display_name && <Badge variant="outline">{user.account.display_name}</Badge>}
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</CardContent>
|
|
51
|
+
</Card>
|
|
52
|
+
|
|
53
|
+
{/* Stats */}
|
|
54
|
+
<div className="grid grid-cols-3 gap-4">
|
|
55
|
+
{stats.map(({ label, value, loading }) => (
|
|
56
|
+
<Card key={label}>
|
|
57
|
+
<CardContent className="py-5 text-center">
|
|
58
|
+
{loading ? (
|
|
59
|
+
<Skeleton className="h-8 w-12 mx-auto mb-1" />
|
|
60
|
+
) : (
|
|
61
|
+
<p className="text-2xl font-semibold">{value}</p>
|
|
62
|
+
)}
|
|
63
|
+
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
|
|
64
|
+
</CardContent>
|
|
65
|
+
</Card>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
{/* Account details */}
|
|
70
|
+
<Card>
|
|
71
|
+
<CardHeader>
|
|
72
|
+
<p className="text-sm font-medium">Contact Details</p>
|
|
73
|
+
</CardHeader>
|
|
74
|
+
<CardContent className="space-y-3 text-sm">
|
|
75
|
+
<div className="flex justify-between">
|
|
76
|
+
<span className="text-muted-foreground">Name</span>
|
|
77
|
+
<span className="font-medium">{user?.full_name || '—'}</span>
|
|
78
|
+
</div>
|
|
79
|
+
<Separator />
|
|
80
|
+
<div className="flex justify-between">
|
|
81
|
+
<span className="text-muted-foreground">Email</span>
|
|
82
|
+
<span className="font-medium">{user?.email}</span>
|
|
83
|
+
</div>
|
|
84
|
+
<Separator />
|
|
85
|
+
<div className="flex justify-between">
|
|
86
|
+
<span className="text-muted-foreground">Role</span>
|
|
87
|
+
<span className="font-medium capitalize">{user?.roles?.[0] || 'member'}</span>
|
|
88
|
+
</div>
|
|
89
|
+
</CardContent>
|
|
90
|
+
</Card>
|
|
91
|
+
|
|
92
|
+
{/* Settings stubs */}
|
|
93
|
+
<Card>
|
|
94
|
+
<CardHeader>
|
|
95
|
+
<p className="text-sm font-medium">Account Settings</p>
|
|
96
|
+
</CardHeader>
|
|
97
|
+
<CardContent className="space-y-4">
|
|
98
|
+
{[
|
|
99
|
+
{ label: 'Email Notifications', desc: 'Receive updates about your activities' },
|
|
100
|
+
{ label: 'Privacy Settings', desc: 'Control your data and visibility' },
|
|
101
|
+
{ label: 'API Access', desc: 'Manage API keys and integrations' },
|
|
102
|
+
].map(({ label, desc }) => (
|
|
103
|
+
<div key={label} className="flex items-center justify-between">
|
|
104
|
+
<div>
|
|
105
|
+
<p className="text-sm font-medium">{label}</p>
|
|
106
|
+
<p className="text-xs text-muted-foreground">{desc}</p>
|
|
107
|
+
</div>
|
|
108
|
+
<Button variant="outline" size="sm">Configure</Button>
|
|
109
|
+
</div>
|
|
110
|
+
))}
|
|
111
|
+
</CardContent>
|
|
112
|
+
</Card>
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Plus, Send, Users, Hash, MessageSquarePlus } from 'lucide-react'
|
|
3
|
+
import { useCommunityPosts, useCreatePost, type CommunityPost } from '../hooks/useCommunity'
|
|
4
|
+
import { usePortalThread } from '../hooks/usePortalThreads'
|
|
5
|
+
import { usePortalSignal } from '../hooks/usePortalSignal'
|
|
6
|
+
import { SearchFilterBar } from '../components/SearchFilterBar'
|
|
7
|
+
import { Button } from '@core/components/ui/button'
|
|
8
|
+
import { Input } from '@core/components/ui/input'
|
|
9
|
+
import { Textarea } from '@core/components/ui/textarea'
|
|
10
|
+
import { Label } from '@core/components/ui/label'
|
|
11
|
+
import { Skeleton } from '@core/components/ui/skeleton'
|
|
12
|
+
import { ScrollArea } from '@core/components/ui/scroll-area'
|
|
13
|
+
import { Badge } from '@core/components/ui/badge'
|
|
14
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@core/components/ui/dialog'
|
|
15
|
+
|
|
16
|
+
const CHANNELS = ['general', 'announcements', 'help', 'show-and-tell']
|
|
17
|
+
const CHANNEL_LABELS: Record<string, string> = {
|
|
18
|
+
general: 'General',
|
|
19
|
+
announcements: 'Announcements',
|
|
20
|
+
help: 'Help',
|
|
21
|
+
'show-and-tell': 'Show & Tell',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ThreadPane({ post }: { post: CommunityPost }) {
|
|
25
|
+
const { messages, loading, reply } = usePortalThread('items', post.id)
|
|
26
|
+
const [replyText, setReplyText] = useState('')
|
|
27
|
+
const [sending, setSending] = useState(false)
|
|
28
|
+
const { sendSignal } = usePortalSignal()
|
|
29
|
+
|
|
30
|
+
const handleSend = async () => {
|
|
31
|
+
if (!replyText.trim()) return
|
|
32
|
+
setSending(true)
|
|
33
|
+
try {
|
|
34
|
+
await reply(replyText.trim())
|
|
35
|
+
setReplyText('')
|
|
36
|
+
sendSignal('community_reply', 'Replied to community thread')
|
|
37
|
+
}
|
|
38
|
+
catch (e: any) { console.error(e) }
|
|
39
|
+
finally { setSending(false) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex flex-col h-full">
|
|
44
|
+
<div className="px-6 py-3 border-b border-border shrink-0">
|
|
45
|
+
<h3 className="text-sm font-semibold">{post.title}</h3>
|
|
46
|
+
{post.description && (
|
|
47
|
+
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">{post.description}</p>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
<ScrollArea className="flex-1 p-4">
|
|
51
|
+
<div className="space-y-3">
|
|
52
|
+
{loading && <div className="space-y-2"><Skeleton className="h-10 w-2/3" /><Skeleton className="h-10 w-1/2 ml-auto" /></div>}
|
|
53
|
+
{!loading && messages.length === 0 && (
|
|
54
|
+
<p className="text-sm text-muted-foreground italic text-center py-8">No replies yet. Be the first to respond.</p>
|
|
55
|
+
)}
|
|
56
|
+
{messages.map((msg) => (
|
|
57
|
+
<div key={msg.id} className={`flex ${msg.direction === 'inbound' ? 'justify-end' : 'justify-start'}`}>
|
|
58
|
+
<div className={`max-w-sm rounded-lg px-3 py-2 text-sm ${
|
|
59
|
+
msg.direction === 'inbound'
|
|
60
|
+
? 'bg-primary text-primary-foreground'
|
|
61
|
+
: 'bg-muted text-foreground border border-border'
|
|
62
|
+
}`}>{msg.content}</div>
|
|
63
|
+
</div>
|
|
64
|
+
))}
|
|
65
|
+
</div>
|
|
66
|
+
</ScrollArea>
|
|
67
|
+
<div className="border-t p-3 flex gap-2 shrink-0">
|
|
68
|
+
<Input placeholder="Reply…" value={replyText} onChange={(e) => setReplyText(e.target.value)}
|
|
69
|
+
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()} className="flex-1" />
|
|
70
|
+
<Button size="icon" onClick={handleSend} disabled={sending || !replyText.trim()}><Send size={14} /></Button>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function NewPostDialog({ open, onOpenChange, onCreated, defaultChannel }: {
|
|
77
|
+
open: boolean; onOpenChange: (v: boolean) => void; onCreated: () => void; defaultChannel: string
|
|
78
|
+
}) {
|
|
79
|
+
const { createPost, loading } = useCreatePost()
|
|
80
|
+
const [title, setTitle] = useState('')
|
|
81
|
+
const [description, setDescription] = useState('')
|
|
82
|
+
const [channel, setChannel] = useState(defaultChannel)
|
|
83
|
+
const { sendSignal } = usePortalSignal()
|
|
84
|
+
|
|
85
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
86
|
+
e.preventDefault()
|
|
87
|
+
await createPost({ title, description, data: { channel } })
|
|
88
|
+
sendSignal('community_post_create', `Created community post: ${title}`)
|
|
89
|
+
onCreated(); onOpenChange(false); setTitle(''); setDescription('')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
94
|
+
<DialogContent className="sm:max-w-md">
|
|
95
|
+
<DialogHeader><DialogTitle>New Discussion</DialogTitle></DialogHeader>
|
|
96
|
+
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
|
97
|
+
<div className="space-y-1.5">
|
|
98
|
+
<Label htmlFor="post-title">Title</Label>
|
|
99
|
+
<Input id="post-title" placeholder="What's your question or topic?"
|
|
100
|
+
value={title} onChange={(e) => setTitle(e.target.value)} required />
|
|
101
|
+
</div>
|
|
102
|
+
<div className="space-y-1.5">
|
|
103
|
+
<Label htmlFor="post-body">Details</Label>
|
|
104
|
+
<Textarea id="post-body" placeholder="Add more context…" rows={4}
|
|
105
|
+
value={description} onChange={(e) => setDescription(e.target.value)} />
|
|
106
|
+
</div>
|
|
107
|
+
<DialogFooter>
|
|
108
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
|
109
|
+
<Button type="submit" disabled={loading || !title.trim()}>{loading ? 'Posting…' : 'Post'}</Button>
|
|
110
|
+
</DialogFooter>
|
|
111
|
+
</form>
|
|
112
|
+
</DialogContent>
|
|
113
|
+
</Dialog>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function CommunityPage() {
|
|
118
|
+
const [search, setSearch] = useState('')
|
|
119
|
+
const [activeChannel, setActiveChannel] = useState('general')
|
|
120
|
+
const [selected, setSelected] = useState<CommunityPost | null>(null)
|
|
121
|
+
const [showNew, setShowNew] = useState(false)
|
|
122
|
+
|
|
123
|
+
const { posts, loading, refetch } = useCommunityPosts()
|
|
124
|
+
const { sendSignal } = usePortalSignal()
|
|
125
|
+
|
|
126
|
+
const channelPosts = posts.filter((p) => (p.data?.channel as string || 'general') === activeChannel)
|
|
127
|
+
const filtered = channelPosts.filter((p) => p.title.toLowerCase().includes(search.toLowerCase()))
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className="flex h-full min-h-0">
|
|
131
|
+
{/* Col 1 — channels sidebar */}
|
|
132
|
+
<div className="w-48 shrink-0 border-r border-border bg-muted/30 flex flex-col min-h-0">
|
|
133
|
+
<div className="flex items-center px-3 py-2 h-9 border-b border-border shrink-0">
|
|
134
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Channels</p>
|
|
135
|
+
</div>
|
|
136
|
+
<div className="flex-1 overflow-y-auto py-1">
|
|
137
|
+
{CHANNELS.map((ch) => {
|
|
138
|
+
const count = posts.filter((p) => (p.data?.channel as string || 'general') === ch).length
|
|
139
|
+
return (
|
|
140
|
+
<button key={ch} onClick={() => { setActiveChannel(ch); setSelected(null) }}
|
|
141
|
+
className={`w-full text-left px-3 py-2 flex items-center gap-2 hover:bg-accent/50 transition-colors ${
|
|
142
|
+
activeChannel === ch ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground'
|
|
143
|
+
}`}
|
|
144
|
+
>
|
|
145
|
+
<Hash size={13} className="shrink-0" />
|
|
146
|
+
<span className="text-sm flex-1 truncate">{CHANNEL_LABELS[ch]}</span>
|
|
147
|
+
{count > 0 && <Badge variant="secondary" className="text-xs h-4 px-1.5 min-w-4">{count}</Badge>}
|
|
148
|
+
</button>
|
|
149
|
+
)
|
|
150
|
+
})}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{/* Col 2 — posts list */}
|
|
155
|
+
<div className="w-72 shrink-0 border-r border-border flex flex-col min-h-0">
|
|
156
|
+
<div className="flex items-center gap-2 px-3 py-2 h-9 border-b border-border shrink-0">
|
|
157
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex-1">
|
|
158
|
+
#{CHANNEL_LABELS[activeChannel]}
|
|
159
|
+
</p>
|
|
160
|
+
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => setShowNew(true)}>
|
|
161
|
+
<Plus size={13} />
|
|
162
|
+
</Button>
|
|
163
|
+
</div>
|
|
164
|
+
<div className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
|
165
|
+
<SearchFilterBar placeholder="Search…" value={search} onChange={setSearch} />
|
|
166
|
+
</div>
|
|
167
|
+
<div className="flex-1 overflow-y-auto">
|
|
168
|
+
{loading ? (
|
|
169
|
+
<div className="p-3 space-y-2">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-12 w-full" />)}</div>
|
|
170
|
+
) : filtered.length === 0 ? (
|
|
171
|
+
<div className="flex flex-col items-center justify-center gap-3 py-12 px-4 text-center">
|
|
172
|
+
<MessageSquarePlus size={28} className="text-muted-foreground/40" />
|
|
173
|
+
<p className="text-sm text-muted-foreground">No discussions yet.</p>
|
|
174
|
+
<Button size="sm" variant="outline" onClick={() => setShowNew(true)} className="gap-1.5">
|
|
175
|
+
<Plus size={13} /> Start a discussion
|
|
176
|
+
</Button>
|
|
177
|
+
</div>
|
|
178
|
+
) : (
|
|
179
|
+
filtered.map((post) => (
|
|
180
|
+
<button key={post.id} onClick={() => { setSelected(post); sendSignal('community_post_view', `Viewed community post: ${post.title}`) }}
|
|
181
|
+
className={`w-full text-left px-3 py-2.5 border-b border-border hover:bg-accent/50 transition-colors ${
|
|
182
|
+
selected?.id === post.id ? 'bg-accent border-l-2 border-l-primary' : ''
|
|
183
|
+
}`}
|
|
184
|
+
>
|
|
185
|
+
<p className={`text-sm font-medium truncate ${selected?.id === post.id ? 'text-primary' : ''}`}>{post.title}</p>
|
|
186
|
+
{post.description && <p className="text-xs text-muted-foreground truncate mt-0.5">{post.description}</p>}
|
|
187
|
+
</button>
|
|
188
|
+
))
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{/* Col 3 — thread */}
|
|
194
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
195
|
+
{!selected ? (
|
|
196
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
|
197
|
+
<Users size={32} className="opacity-30" />
|
|
198
|
+
<p className="text-sm">Select a discussion to read replies</p>
|
|
199
|
+
</div>
|
|
200
|
+
) : (
|
|
201
|
+
<ThreadPane post={selected} />
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<NewPostDialog open={showNew} onOpenChange={setShowNew} onCreated={refetch} defaultChannel={activeChannel} />
|
|
206
|
+
</div>
|
|
207
|
+
)
|
|
208
|
+
}
|