Files
lunarfront-app/packages/admin/src/routes/_authenticated/help.tsx

165 lines
5.7 KiB
TypeScript

import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { getWikiCategories, getWikiPage } from '@/wiki'
import { Input } from '@/components/ui/input'
import { Search, ChevronDown, ChevronRight } from 'lucide-react'
export const Route = createFileRoute('/_authenticated/help')({
validateSearch: (search: Record<string, unknown>) => ({
page: (search.page as string) || 'getting-started',
}),
component: HelpPage,
})
function renderMarkdown(content: string) {
// Simple markdown to HTML — handles headers, bold, lists, tips, notes
return content
.split('\n\n')
.map((block, i) => {
const trimmed = block.trim()
if (!trimmed) return null
// Headers
if (trimmed.startsWith('## '))
return <h2 key={i} className="text-lg font-semibold mt-6 mb-2">{trimmed.slice(3)}</h2>
if (trimmed.startsWith('# '))
return <h1 key={i} className="text-2xl font-bold mb-4">{trimmed.slice(2)}</h1>
// Lists
if (trimmed.match(/^\d+\./m) || trimmed.startsWith('- ')) {
const items = trimmed.split('\n').filter(Boolean)
const isOrdered = items[0].match(/^\d+\./)
const Tag = isOrdered ? 'ol' : 'ul'
return (
<Tag key={i} className={`my-2 pl-6 space-y-1 ${isOrdered ? 'list-decimal' : 'list-disc'}`}>
{items.map((item, j) => (
<li key={j} className="text-sm">
{renderInline(item.replace(/^(\d+\.\s*|-\s*)/, ''))}
</li>
))}
</Tag>
)
}
// Paragraph
return <p key={i} className="text-sm leading-relaxed my-2">{renderInline(trimmed)}</p>
})
.filter(Boolean)
}
function renderInline(text: string) {
// Bold
const parts = text.split(/(\*\*[^*]+\*\*)/)
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={i}>{part.slice(2, -2)}</strong>
}
return part
})
}
function HelpPage() {
const categories = getWikiCategories()
const search = Route.useSearch()
const navigate = Route.useNavigate()
const currentPage = getWikiPage(search.page)
const [searchQuery, setSearchQuery] = useState('')
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const allPages = categories.flatMap((c) => c.pages)
const filteredPages = searchQuery
? allPages.filter(
(p) =>
p.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.content.toLowerCase().includes(searchQuery.toLowerCase()),
)
: null
function goToPage(slug: string) {
navigate({ search: { page: slug } })
setSearchQuery('')
}
function toggleCategory(name: string) {
setCollapsed((prev) => ({ ...prev, [name]: !prev[name] }))
}
return (
<div className="flex gap-6 max-w-5xl h-[calc(100vh-8rem)]">
{/* Sidebar */}
<div className="w-56 shrink-0 flex flex-col gap-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search help..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 text-sm"
/>
</div>
<div className="overflow-y-auto flex-1 space-y-1 pr-1">
{filteredPages ? (
filteredPages.length === 0 ? (
<p className="text-sm text-muted-foreground px-2">No results</p>
) : (
filteredPages.map((p) => (
<button
key={p.slug}
onClick={() => goToPage(p.slug)}
className="block w-full text-left px-2 py-1.5 text-sm rounded-md hover:bg-accent"
>
{p.title}
</button>
))
)
) : (
categories.map((cat) => {
const isCollapsed = collapsed[cat.name] ?? false
return (
<div key={cat.name}>
<button
onClick={() => toggleCategory(cat.name)}
className="flex items-center justify-between w-full px-2 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide hover:text-foreground transition-colors"
>
{cat.name}
{isCollapsed
? <ChevronRight className="h-3 w-3" />
: <ChevronDown className="h-3 w-3" />}
</button>
{!isCollapsed && (
<div className="space-y-0.5 mt-0.5">
{cat.pages.map((p) => (
<button
key={p.slug}
onClick={() => goToPage(p.slug)}
className={`block w-full text-left px-2 py-1.5 text-sm rounded-md transition-colors ${
search.page === p.slug
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
}`}
>
{p.title}
</button>
))}
</div>
)}
</div>
)
})
)}
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0 overflow-y-auto">
{currentPage ? (
<div className="prose-sm">{renderMarkdown(currentPage.content)}</div>
) : (
<p className="text-muted-foreground">Page not found</p>
)}
</div>
</div>
)
}