@geenius/docs 0.1.0
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/.changeset/config.json +11 -0
- package/.github/CODEOWNERS +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +16 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +11 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +10 -0
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/ci.yml +23 -0
- package/.github/workflows/release.yml +29 -0
- package/.nvmrc +1 -0
- package/.project/ACCOUNT.yaml +4 -0
- package/.project/IDEAS.yaml +7 -0
- package/.project/PROJECT.yaml +11 -0
- package/.project/ROADMAP.yaml +15 -0
- package/CHANGELOG.md +11 -0
- package/CODE_OF_CONDUCT.md +16 -0
- package/CONTRIBUTING.md +26 -0
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/SECURITY.md +15 -0
- package/SUPPORT.md +8 -0
- package/package.json +58 -0
- package/packages/convex/README.md +1 -0
- package/packages/convex/package.json +12 -0
- package/packages/convex/src/convex.config.ts +3 -0
- package/packages/convex/src/index.ts +3 -0
- package/packages/convex/src/mutations.ts +270 -0
- package/packages/convex/src/queries.ts +175 -0
- package/packages/convex/src/schema.ts +55 -0
- package/packages/react/README.md +1 -0
- package/packages/react/package.json +36 -0
- package/packages/react/src/DocsLayout.tsx +116 -0
- package/packages/react/src/DocsProvider.tsx +93 -0
- package/packages/react/src/RouterDocsContent.tsx +148 -0
- package/packages/react/src/RouterDocsLayout.tsx +161 -0
- package/packages/react/src/components/Breadcrumbs.tsx +34 -0
- package/packages/react/src/components/DocPage.tsx +191 -0
- package/packages/react/src/components/DocSearch.tsx +140 -0
- package/packages/react/src/components/DocSidebar.tsx +86 -0
- package/packages/react/src/components/DocsLayout.tsx +62 -0
- package/packages/react/src/components/EditButton.tsx +26 -0
- package/packages/react/src/components/PageNavigation.tsx +45 -0
- package/packages/react/src/components/TableOfContents.tsx +46 -0
- package/packages/react/src/components/VersionSelector.tsx +60 -0
- package/packages/react/src/components/index.ts +9 -0
- package/packages/react/src/hooks/index.ts +8 -0
- package/packages/react/src/hooks/useDocSearch.ts +55 -0
- package/packages/react/src/hooks/useDocs.ts +57 -0
- package/packages/react/src/hooks/useDocsAdmin.ts +151 -0
- package/packages/react/src/hooks/useTableOfContents.ts +66 -0
- package/packages/react/src/index.ts +38 -0
- package/packages/react/src/pages/DocSearchPage.tsx +129 -0
- package/packages/react/src/pages/DocViewPage.tsx +158 -0
- package/packages/react/src/pages/DocsAdminPage.tsx +330 -0
- package/packages/react/src/pages/DocsIndexPage.tsx +172 -0
- package/packages/react/src/pages/index.ts +4 -0
- package/packages/react/src/useDocs.ts +58 -0
- package/packages/react/tsup.config.ts +12 -0
- package/packages/react-css/README.md +1 -0
- package/packages/react-css/package.json +37 -0
- package/packages/react-css/src/DocsLayout.tsx +117 -0
- package/packages/react-css/src/DocsProvider.tsx +93 -0
- package/packages/react-css/src/RouterDocsContent.tsx +60 -0
- package/packages/react-css/src/RouterDocsLayout.tsx +101 -0
- package/packages/react-css/src/components/DocPage.tsx +21 -0
- package/packages/react-css/src/components/DocSearch.tsx +55 -0
- package/packages/react-css/src/components/DocSidebar.tsx +56 -0
- package/packages/react-css/src/components/DocsLayout.tsx +28 -0
- package/packages/react-css/src/components/common.tsx +93 -0
- package/packages/react-css/src/components/index.ts +5 -0
- package/packages/react-css/src/hooks/index.ts +2 -0
- package/packages/react-css/src/index.ts +6 -0
- package/packages/react-css/src/index.tsx +3 -0
- package/packages/react-css/src/pages/DocViewPage.tsx +78 -0
- package/packages/react-css/src/pages/DocsAdminPage.tsx +101 -0
- package/packages/react-css/src/pages/DocsIndexPage.tsx +68 -0
- package/packages/react-css/src/pages/index.ts +3 -0
- package/packages/react-css/src/styles.css +1271 -0
- package/packages/react-css/src/useDocs.ts +58 -0
- package/packages/react-css/tsconfig.json +19 -0
- package/packages/react-css/tsup.config.ts +10 -0
- package/packages/shared/README.md +1 -0
- package/packages/shared/package.json +31 -0
- package/packages/shared/src/__tests__/docs.test.ts +69 -0
- package/packages/shared/src/config.ts +80 -0
- package/packages/shared/src/index.ts +179 -0
- package/packages/shared/src/providers/astro.ts +94 -0
- package/packages/shared/src/providers/fumadocs.ts +116 -0
- package/packages/shared/src/providers/internal.ts +80 -0
- package/packages/shared/src/types.ts +73 -0
- package/packages/shared/tsconfig.json +18 -0
- package/packages/shared/tsup.config.ts +12 -0
- package/packages/shared/vitest.config.ts +4 -0
- package/packages/solidjs/README.md +1 -0
- package/packages/solidjs/package.json +33 -0
- package/packages/solidjs/src/DocsLayout.tsx +87 -0
- package/packages/solidjs/src/DocsProvider.tsx +95 -0
- package/packages/solidjs/src/RouterDocsContent.tsx +147 -0
- package/packages/solidjs/src/RouterDocsLayout.tsx +161 -0
- package/packages/solidjs/src/components/Breadcrumbs.tsx +27 -0
- package/packages/solidjs/src/components/DocPage.tsx +110 -0
- package/packages/solidjs/src/components/DocSearch.tsx +81 -0
- package/packages/solidjs/src/components/DocSidebar.tsx +92 -0
- package/packages/solidjs/src/components/DocsLayout.tsx +38 -0
- package/packages/solidjs/src/components/EditButton.tsx +15 -0
- package/packages/solidjs/src/components/PageNavigation.tsx +31 -0
- package/packages/solidjs/src/components/TableOfContents.tsx +41 -0
- package/packages/solidjs/src/components/VersionSelector.tsx +30 -0
- package/packages/solidjs/src/components/index.ts +9 -0
- package/packages/solidjs/src/createDocs.ts +62 -0
- package/packages/solidjs/src/index.ts +28 -0
- package/packages/solidjs/src/pages/DocSearchPage.tsx +72 -0
- package/packages/solidjs/src/pages/DocViewPage.tsx +80 -0
- package/packages/solidjs/src/pages/DocsAdminPage.tsx +123 -0
- package/packages/solidjs/src/pages/DocsIndexPage.tsx +85 -0
- package/packages/solidjs/src/pages/index.ts +4 -0
- package/packages/solidjs/src/primitives/createDocSearch.ts +42 -0
- package/packages/solidjs/src/primitives/createDocs.ts +35 -0
- package/packages/solidjs/src/primitives/createDocsAdmin.ts +63 -0
- package/packages/solidjs/src/primitives/createTableOfContents.ts +51 -0
- package/packages/solidjs/src/primitives/index.ts +4 -0
- package/packages/solidjs/tsup.config.ts +12 -0
- package/packages/solidjs-css/README.md +1 -0
- package/packages/solidjs-css/package.json +36 -0
- package/packages/solidjs-css/src/DocsLayout.tsx +106 -0
- package/packages/solidjs-css/src/DocsProvider.tsx +95 -0
- package/packages/solidjs-css/src/RouterDocsContent.tsx +54 -0
- package/packages/solidjs-css/src/RouterDocsLayout.tsx +104 -0
- package/packages/solidjs-css/src/createDocs.ts +62 -0
- package/packages/solidjs-css/src/index.ts +7 -0
- package/packages/solidjs-css/src/index.tsx +17 -0
- package/packages/solidjs-css/src/pages/DocViewPage.tsx +111 -0
- package/packages/solidjs-css/src/pages/DocsAdminPage.tsx +332 -0
- package/packages/solidjs-css/src/pages/DocsIndexPage.tsx +116 -0
- package/packages/solidjs-css/src/pages/index.ts +3 -0
- package/packages/solidjs-css/src/primitives/index.ts +1 -0
- package/packages/solidjs-css/src/styles.css +1271 -0
- package/packages/solidjs-css/tsconfig.json +20 -0
- package/packages/solidjs-css/tsup.config.ts +10 -0
- package/pnpm-workspace.yaml +2 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import React, { useCallback, useMemo } from 'react'
|
|
2
|
+
import type { DocPage, DocSection, SearchResult } from '@geenius-docs/shared'
|
|
3
|
+
import { buildDocsIndex, searchDocs, highlightMatch } from '@geenius-docs/shared'
|
|
4
|
+
import { useDocSearch } from '../hooks/useDocSearch'
|
|
5
|
+
|
|
6
|
+
interface DocSearchPageProps {
|
|
7
|
+
tree: (DocSection & { pages: DocPage[]; pageCount: number })[] | undefined
|
|
8
|
+
onSelectPage?: (page: DocPage, section: DocSection) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function DocSearchPage({ tree, onSelectPage }: DocSearchPageProps) {
|
|
12
|
+
const sections = useMemo(() => tree ?? [], [tree])
|
|
13
|
+
const flatPages = useMemo(() => sections.flatMap((s) => s.pages ?? []), [sections])
|
|
14
|
+
|
|
15
|
+
const searchFn = useCallback(
|
|
16
|
+
(q: string): SearchResult[] => {
|
|
17
|
+
const index = buildDocsIndex(flatPages, sections)
|
|
18
|
+
return searchDocs(q, index)
|
|
19
|
+
},
|
|
20
|
+
[flatPages, sections]
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
const { results, isSearching, query, setQuery } = useDocSearch(searchFn)
|
|
24
|
+
|
|
25
|
+
// Loading
|
|
26
|
+
if (tree === undefined) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="min-h-screen bg-[#090a0f] px-6 py-16">
|
|
29
|
+
<div className="mx-auto max-w-2xl">
|
|
30
|
+
<div className="mb-8 h-12 animate-pulse rounded-xl bg-white/5" />
|
|
31
|
+
<div className="space-y-3">
|
|
32
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
33
|
+
<div key={i} className="h-20 animate-pulse rounded-xl bg-white/5" />
|
|
34
|
+
))}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="min-h-screen bg-[#090a0f] text-white">
|
|
43
|
+
<div className="mx-auto max-w-2xl px-6 py-16">
|
|
44
|
+
<h1 className="mb-8 text-2xl font-bold">Search Documentation</h1>
|
|
45
|
+
|
|
46
|
+
{/* Search input */}
|
|
47
|
+
<div className="relative mb-8">
|
|
48
|
+
<svg className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-white/30" viewBox="0 0 20 20" fill="currentColor">
|
|
49
|
+
<path fillRule="evenorid" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
|
|
50
|
+
</svg>
|
|
51
|
+
<input
|
|
52
|
+
type="text"
|
|
53
|
+
value={query}
|
|
54
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
55
|
+
placeholder="Type to search…"
|
|
56
|
+
autoFocus
|
|
57
|
+
className="w-full rounded-xl border border-white/10 bg-white/5 py-3.5 pl-12 pr-4 text-sm text-white placeholder-white/30 outline-none transition-colors focus:border-indigo-500/40 focus:ring-1 focus:ring-indigo-500/20"
|
|
58
|
+
/>
|
|
59
|
+
{isSearching && (
|
|
60
|
+
<div className="absolute right-4 top-1/2 -translate-y-1/2">
|
|
61
|
+
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/10 border-t-indigo-400" />
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* Results */}
|
|
67
|
+
{results.length > 0 && (
|
|
68
|
+
<div className="space-y-2">
|
|
69
|
+
{results.map((result) => {
|
|
70
|
+
const section = sections.find((s) => s.slug === result.sectionSlug)
|
|
71
|
+
const page = flatPages.find((p) => p.id === result.pageId)
|
|
72
|
+
return (
|
|
73
|
+
<button
|
|
74
|
+
key={result.pageId}
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={() => {
|
|
77
|
+
if (page && section && onSelectPage) onSelectPage(page, section)
|
|
78
|
+
}}
|
|
79
|
+
className="flex w-full flex-col gap-1.5 rounded-xl border border-white/5 bg-white/[0.02] p-4 text-left transition-all hover:border-indigo-500/20 hover:bg-white/[0.05]"
|
|
80
|
+
>
|
|
81
|
+
<div className="flex items-center gap-2">
|
|
82
|
+
<span className="rounded bg-indigo-500/20 px-2 py-0.5 text-[10px] font-medium text-indigo-300">
|
|
83
|
+
{result.sectionTitle}
|
|
84
|
+
</span>
|
|
85
|
+
<span className="text-sm font-medium text-white/90">{result.pageTitle}</span>
|
|
86
|
+
<span className="ml-auto text-xs tabular-nums text-white/20">
|
|
87
|
+
{page?.readingTime ?? 0} min
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
<p className="text-xs leading-relaxed text-white/40">
|
|
91
|
+
{highlightMatch(result.highlight, query)}
|
|
92
|
+
</p>
|
|
93
|
+
{result.tags.length > 0 && (
|
|
94
|
+
<div className="flex gap-1 mt-1">
|
|
95
|
+
{result.tags.slice(0, 4).map((tag) => (
|
|
96
|
+
<span key={tag} className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-white/30">
|
|
97
|
+
{tag}
|
|
98
|
+
</span>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
</button>
|
|
103
|
+
)
|
|
104
|
+
})}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{/* Empty state */}
|
|
109
|
+
{query.trim() && results.length === 0 && !isSearching && (
|
|
110
|
+
<div className="flex flex-col items-center py-16 text-center">
|
|
111
|
+
<div className="mb-4 text-5xl opacity-30">🔍</div>
|
|
112
|
+
<h3 className="mb-2 text-lg font-medium text-white/60">No results found</h3>
|
|
113
|
+
<p className="text-sm text-white/30">
|
|
114
|
+
Try different keywords or check your spelling.
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{/* Initial state */}
|
|
120
|
+
{!query.trim() && (
|
|
121
|
+
<div className="flex flex-col items-center py-16 text-center">
|
|
122
|
+
<div className="mb-4 text-5xl opacity-20">📖</div>
|
|
123
|
+
<p className="text-sm text-white/30">Start typing to search across all documentation</p>
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import React, { useEffect, useMemo } from 'react'
|
|
2
|
+
import type { DocPage as DocPageType, DocSection } from '@geenius-docs/shared'
|
|
3
|
+
import { buildBreadcrumbs } from '@geenius-docs/shared'
|
|
4
|
+
import { useDocs } from '../hooks/useDocs'
|
|
5
|
+
import { useTableOfContents } from '../hooks/useTableOfContents'
|
|
6
|
+
import { DocsLayout } from '../components/DocsLayout'
|
|
7
|
+
import { DocPage } from '../components/DocPage'
|
|
8
|
+
import { EditButton } from '../components/EditButton'
|
|
9
|
+
import { PageNavigation } from '../components/PageNavigation'
|
|
10
|
+
|
|
11
|
+
interface DocViewPageProps {
|
|
12
|
+
tree: (DocSection & { pages: DocPageType[]; pageCount: number })[] | undefined
|
|
13
|
+
page: DocPageType | null | undefined
|
|
14
|
+
editPageUrl?: string
|
|
15
|
+
onNavigate: (page: DocPageType, section: DocSection) => void
|
|
16
|
+
onIncrementView?: (pageId: string) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function DocViewPage({
|
|
20
|
+
tree,
|
|
21
|
+
page,
|
|
22
|
+
editPageUrl,
|
|
23
|
+
onNavigate,
|
|
24
|
+
onIncrementView,
|
|
25
|
+
}: DocViewPageProps) {
|
|
26
|
+
const docs = useDocs(tree)
|
|
27
|
+
const { toc, activeId } = useTableOfContents(page?.content)
|
|
28
|
+
|
|
29
|
+
// Increment view count on mount
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (page?.id && onIncrementView) {
|
|
32
|
+
onIncrementView(page.id)
|
|
33
|
+
}
|
|
34
|
+
}, [page?.id, onIncrementView])
|
|
35
|
+
|
|
36
|
+
// Build breadcrumbs
|
|
37
|
+
const breadcrumbs = useMemo(() => {
|
|
38
|
+
if (!page) return []
|
|
39
|
+
return buildBreadcrumbs(docs.sections, page.sectionId, page.slug)
|
|
40
|
+
}, [docs.sections, page])
|
|
41
|
+
|
|
42
|
+
// Find prev/next pages
|
|
43
|
+
const { prev, next } = useMemo(() => {
|
|
44
|
+
if (!page) return { prev: undefined, next: undefined }
|
|
45
|
+
const allPages = docs.flatPages
|
|
46
|
+
const idx = allPages.findIndex((p) => p.id === page.id)
|
|
47
|
+
const section = docs.sections.find((s) => s.id === page.sectionId)
|
|
48
|
+
return {
|
|
49
|
+
prev: idx > 0
|
|
50
|
+
? { title: allPages[idx - 1].title, href: `/docs/${section?.slug ?? ''}/${allPages[idx - 1].slug}` }
|
|
51
|
+
: undefined,
|
|
52
|
+
next: idx < allPages.length - 1
|
|
53
|
+
? { title: allPages[idx + 1].title, href: `/docs/${section?.slug ?? ''}/${allPages[idx + 1].slug}` }
|
|
54
|
+
: undefined,
|
|
55
|
+
}
|
|
56
|
+
}, [page, docs.flatPages, docs.sections])
|
|
57
|
+
|
|
58
|
+
// Loading
|
|
59
|
+
if (docs.isLoading || page === undefined) {
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex min-h-screen bg-[#090a0f]">
|
|
62
|
+
<div className="hidden w-[260px] shrink-0 border-r border-white/5 bg-[#0b0c12] lg:block">
|
|
63
|
+
<div className="space-y-3 p-4">
|
|
64
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
65
|
+
<div key={i} className="h-8 animate-pulse rounded-lg bg-white/5" />
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<div className="flex-1 px-10 py-8">
|
|
70
|
+
<div className="mx-auto max-w-3xl space-y-4">
|
|
71
|
+
<div className="h-5 w-48 animate-pulse rounded bg-white/5" />
|
|
72
|
+
<div className="h-10 w-96 animate-pulse rounded bg-white/5" />
|
|
73
|
+
<div className="mt-8 space-y-3">
|
|
74
|
+
{Array.from({ length: 12 }).map((_, i) => (
|
|
75
|
+
<div key={i} className="h-4 animate-pulse rounded bg-white/5" style={{ width: `${60 + Math.random() * 40}%` }} />
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Not found
|
|
85
|
+
if (!page) {
|
|
86
|
+
return (
|
|
87
|
+
<div className="flex min-h-screen flex-col items-center justify-center bg-[#090a0f] text-center">
|
|
88
|
+
<div className="mb-4 text-6xl opacity-30">🔍</div>
|
|
89
|
+
<h2 className="mb-2 text-xl font-semibold text-white/80">Page not found</h2>
|
|
90
|
+
<p className="text-sm text-white/40">The documentation page you're looking for doesn't exist.</p>
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<DocsLayout
|
|
97
|
+
sections={docs.sections}
|
|
98
|
+
currentPage={page}
|
|
99
|
+
toc={toc}
|
|
100
|
+
activeHeadingId={activeId}
|
|
101
|
+
breadcrumbs={breadcrumbs}
|
|
102
|
+
currentPageId={page.id}
|
|
103
|
+
onNavigate={onNavigate}
|
|
104
|
+
>
|
|
105
|
+
{/* Page header */}
|
|
106
|
+
<div className="mb-8">
|
|
107
|
+
<h1 className="mb-3 text-3xl font-bold tracking-tight">{page.title}</h1>
|
|
108
|
+
<div className="flex flex-wrap items-center gap-4 text-sm text-white/40">
|
|
109
|
+
<span className="flex items-center gap-1.5">
|
|
110
|
+
{page.author.avatar ? (
|
|
111
|
+
<img src={page.author.avatar} alt="" className="h-5 w-5 rounded-full" />
|
|
112
|
+
) : (
|
|
113
|
+
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-indigo-500/20 text-[10px] text-indigo-300">
|
|
114
|
+
{page.author.name[0]}
|
|
115
|
+
</span>
|
|
116
|
+
)}
|
|
117
|
+
{page.author.name}
|
|
118
|
+
</span>
|
|
119
|
+
{page.readingTime > 0 && (
|
|
120
|
+
<span>{page.readingTime} min read</span>
|
|
121
|
+
)}
|
|
122
|
+
{page.lastEditedBy && (
|
|
123
|
+
<span>
|
|
124
|
+
Edited by {page.lastEditedBy.name}
|
|
125
|
+
</span>
|
|
126
|
+
)}
|
|
127
|
+
{page.viewCount > 0 && (
|
|
128
|
+
<span>{page.viewCount.toLocaleString()} views</span>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{/* Tags */}
|
|
133
|
+
{page.tags.length > 0 && (
|
|
134
|
+
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
135
|
+
{page.tags.map((tag) => (
|
|
136
|
+
<span key={tag} className="rounded-full bg-white/5 px-2.5 py-0.5 text-xs text-white/40">
|
|
137
|
+
{tag}
|
|
138
|
+
</span>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{/* Content */}
|
|
145
|
+
<DocPage page={page} />
|
|
146
|
+
|
|
147
|
+
{/* Footer */}
|
|
148
|
+
<div className="mt-10 flex items-center justify-between border-t border-white/5 pt-6">
|
|
149
|
+
<EditButton pageSlug={page.slug} editUrl={editPageUrl} />
|
|
150
|
+
{page.version && (
|
|
151
|
+
<span className="text-xs text-white/25">v{page.version}</span>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<PageNavigation prev={prev} next={next} />
|
|
156
|
+
</DocsLayout>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import React, { useState, useMemo } from 'react'
|
|
2
|
+
import type { DocPage, DocSection } from '@geenius-docs/shared'
|
|
3
|
+
import type { DocsAdminActions } from '../hooks/useDocsAdmin'
|
|
4
|
+
|
|
5
|
+
interface DocsAdminPageProps {
|
|
6
|
+
tree: (DocSection & { pages: DocPage[]; pageCount: number })[] | undefined
|
|
7
|
+
allPages?: DocPage[]
|
|
8
|
+
admin: DocsAdminActions
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function DocsAdminPage({ tree, allPages, admin }: DocsAdminPageProps) {
|
|
12
|
+
const sections = useMemo(() => tree ?? [], [tree])
|
|
13
|
+
const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null)
|
|
14
|
+
const [sectionForm, setSectionForm] = useState({ title: '', slug: '', description: '', icon: '', access: 'team' as const })
|
|
15
|
+
const [pageForm, setPageForm] = useState({ title: '', slug: '', content: '', access: 'team' as const, tags: '' })
|
|
16
|
+
const [showSectionForm, setShowSectionForm] = useState(false)
|
|
17
|
+
const [showPageForm, setShowPageForm] = useState(false)
|
|
18
|
+
|
|
19
|
+
const selectedSection = sections.find((s) => s.id === selectedSectionId)
|
|
20
|
+
const sectionPages = useMemo(() => {
|
|
21
|
+
if (!selectedSection) return []
|
|
22
|
+
if (allPages) return allPages.filter((p) => p.sectionId === selectedSectionId)
|
|
23
|
+
return selectedSection.pages ?? []
|
|
24
|
+
}, [selectedSection, allPages, selectedSectionId])
|
|
25
|
+
|
|
26
|
+
// Loading
|
|
27
|
+
if (tree === undefined) {
|
|
28
|
+
return (
|
|
29
|
+
<div className="min-h-screen bg-[#090a0f] px-6 py-12">
|
|
30
|
+
<div className="mx-auto max-w-6xl">
|
|
31
|
+
<div className="mb-8 h-10 w-48 animate-pulse rounded bg-white/5" />
|
|
32
|
+
<div className="grid gap-6 md:grid-cols-2">
|
|
33
|
+
<div className="h-96 animate-pulse rounded-xl bg-white/5" />
|
|
34
|
+
<div className="h-96 animate-pulse rounded-xl bg-white/5" />
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="min-h-screen bg-[#090a0f] text-white">
|
|
43
|
+
<div className="mx-auto max-w-6xl px-6 py-12">
|
|
44
|
+
<h1 className="mb-8 text-2xl font-bold">Docs Admin</h1>
|
|
45
|
+
|
|
46
|
+
<div className="grid gap-6 md:grid-cols-2">
|
|
47
|
+
{/* Left panel — Sections */}
|
|
48
|
+
<div className="rounded-xl border border-white/8 bg-white/[0.02] p-5">
|
|
49
|
+
<div className="mb-4 flex items-center justify-between">
|
|
50
|
+
<h2 className="text-lg font-semibold text-white/80">Sections</h2>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
onClick={() => setShowSectionForm(!showSectionForm)}
|
|
54
|
+
className="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium transition-colors hover:bg-indigo-500"
|
|
55
|
+
>
|
|
56
|
+
+ Add Section
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
{/* Section form */}
|
|
61
|
+
{showSectionForm && (
|
|
62
|
+
<div className="mb-4 space-y-2 rounded-lg border border-white/10 bg-white/5 p-4">
|
|
63
|
+
<input
|
|
64
|
+
type="text"
|
|
65
|
+
placeholder="Title"
|
|
66
|
+
value={sectionForm.title}
|
|
67
|
+
onChange={(e) => setSectionForm({ ...sectionForm, title: e.target.value })}
|
|
68
|
+
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none focus:border-indigo-500/40"
|
|
69
|
+
/>
|
|
70
|
+
<input
|
|
71
|
+
type="text"
|
|
72
|
+
placeholder="Slug"
|
|
73
|
+
value={sectionForm.slug}
|
|
74
|
+
onChange={(e) => setSectionForm({ ...sectionForm, slug: e.target.value })}
|
|
75
|
+
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none focus:border-indigo-500/40"
|
|
76
|
+
/>
|
|
77
|
+
<input
|
|
78
|
+
type="text"
|
|
79
|
+
placeholder="Icon (emoji)"
|
|
80
|
+
value={sectionForm.icon}
|
|
81
|
+
onChange={(e) => setSectionForm({ ...sectionForm, icon: e.target.value })}
|
|
82
|
+
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none focus:border-indigo-500/40"
|
|
83
|
+
/>
|
|
84
|
+
<input
|
|
85
|
+
type="text"
|
|
86
|
+
placeholder="Description"
|
|
87
|
+
value={sectionForm.description}
|
|
88
|
+
onChange={(e) => setSectionForm({ ...sectionForm, description: e.target.value })}
|
|
89
|
+
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none focus:border-indigo-500/40"
|
|
90
|
+
/>
|
|
91
|
+
<select
|
|
92
|
+
value={sectionForm.access}
|
|
93
|
+
onChange={(e) => setSectionForm({ ...sectionForm, access: e.target.value as 'public' | 'team' | 'admin' })}
|
|
94
|
+
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none"
|
|
95
|
+
>
|
|
96
|
+
<option value="public">Public</option>
|
|
97
|
+
<option value="team">Team</option>
|
|
98
|
+
<option value="admin">Admin</option>
|
|
99
|
+
</select>
|
|
100
|
+
<div className="flex gap-2">
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={async () => {
|
|
104
|
+
await admin.createSection({
|
|
105
|
+
...sectionForm,
|
|
106
|
+
order: sections.length,
|
|
107
|
+
icon: sectionForm.icon || undefined,
|
|
108
|
+
description: sectionForm.description || undefined,
|
|
109
|
+
})
|
|
110
|
+
setSectionForm({ title: '', slug: '', description: '', icon: '', access: 'team' })
|
|
111
|
+
setShowSectionForm(false)
|
|
112
|
+
}}
|
|
113
|
+
className="rounded-lg bg-indigo-600 px-4 py-2 text-xs font-medium hover:bg-indigo-500"
|
|
114
|
+
>
|
|
115
|
+
Create
|
|
116
|
+
</button>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={() => setShowSectionForm(false)}
|
|
120
|
+
className="rounded-lg border border-white/10 px-4 py-2 text-xs hover:bg-white/5"
|
|
121
|
+
>
|
|
122
|
+
Cancel
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{/* Sections list */}
|
|
129
|
+
<div className="space-y-1">
|
|
130
|
+
{sections.length === 0 && (
|
|
131
|
+
<p className="py-8 text-center text-sm text-white/30">No sections yet</p>
|
|
132
|
+
)}
|
|
133
|
+
{sections.map((section) => (
|
|
134
|
+
<div
|
|
135
|
+
key={section.id}
|
|
136
|
+
className={`group flex items-center gap-2 rounded-lg px-3 py-2.5 transition-colors cursor-pointer ${
|
|
137
|
+
selectedSectionId === section.id
|
|
138
|
+
? 'bg-indigo-500/15 text-indigo-300'
|
|
139
|
+
: 'hover:bg-white/5 text-white/70'
|
|
140
|
+
}`}
|
|
141
|
+
onClick={() => setSelectedSectionId(section.id)}
|
|
142
|
+
>
|
|
143
|
+
{section.icon && <span>{section.icon}</span>}
|
|
144
|
+
<span className="flex-1 text-sm font-medium truncate">{section.title}</span>
|
|
145
|
+
<span className="text-[11px] tabular-nums opacity-40">{section.pageCount ?? 0}</span>
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onClick={(e) => {
|
|
149
|
+
e.stopPropagation()
|
|
150
|
+
if (confirm(`Delete section "${section.title}"?`)) {
|
|
151
|
+
admin.deleteSection(section.id)
|
|
152
|
+
}
|
|
153
|
+
}}
|
|
154
|
+
className="rounded p-1 text-white/20 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-400"
|
|
155
|
+
title="Delete section"
|
|
156
|
+
>
|
|
157
|
+
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor">
|
|
158
|
+
<path d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z" />
|
|
159
|
+
<path fillRule="evenodd" d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118z" clipRule="evenodd" />
|
|
160
|
+
</svg>
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{/* Right panel — Pages */}
|
|
168
|
+
<div className="rounded-xl border border-white/8 bg-white/[0.02] p-5">
|
|
169
|
+
<div className="mb-4 flex items-center justify-between">
|
|
170
|
+
<h2 className="text-lg font-semibold text-white/80">
|
|
171
|
+
{selectedSection ? `Pages — ${selectedSection.title}` : 'Pages'}
|
|
172
|
+
</h2>
|
|
173
|
+
{selectedSection && (
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
onClick={() => setShowPageForm(!showPageForm)}
|
|
177
|
+
className="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium transition-colors hover:bg-indigo-500"
|
|
178
|
+
>
|
|
179
|
+
+ Add Page
|
|
180
|
+
</button>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{!selectedSection && (
|
|
185
|
+
<p className="py-16 text-center text-sm text-white/30">Select a section to manage pages</p>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{/* Page form */}
|
|
189
|
+
{showPageForm && selectedSection && (
|
|
190
|
+
<div className="mb-4 space-y-2 rounded-lg border border-white/10 bg-white/5 p-4">
|
|
191
|
+
<input
|
|
192
|
+
type="text"
|
|
193
|
+
placeholder="Title"
|
|
194
|
+
value={pageForm.title}
|
|
195
|
+
onChange={(e) => setPageForm({ ...pageForm, title: e.target.value })}
|
|
196
|
+
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none focus:border-indigo-500/40"
|
|
197
|
+
/>
|
|
198
|
+
<input
|
|
199
|
+
type="text"
|
|
200
|
+
placeholder="Slug"
|
|
201
|
+
value={pageForm.slug}
|
|
202
|
+
onChange={(e) => setPageForm({ ...pageForm, slug: e.target.value })}
|
|
203
|
+
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none focus:border-indigo-500/40"
|
|
204
|
+
/>
|
|
205
|
+
<textarea
|
|
206
|
+
placeholder="Content (MDX)"
|
|
207
|
+
value={pageForm.content}
|
|
208
|
+
onChange={(e) => setPageForm({ ...pageForm, content: e.target.value })}
|
|
209
|
+
rows={5}
|
|
210
|
+
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none focus:border-indigo-500/40 resize-y"
|
|
211
|
+
/>
|
|
212
|
+
<input
|
|
213
|
+
type="text"
|
|
214
|
+
placeholder="Tags (comma separated)"
|
|
215
|
+
value={pageForm.tags}
|
|
216
|
+
onChange={(e) => setPageForm({ ...pageForm, tags: e.target.value })}
|
|
217
|
+
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none focus:border-indigo-500/40"
|
|
218
|
+
/>
|
|
219
|
+
<select
|
|
220
|
+
value={pageForm.access}
|
|
221
|
+
onChange={(e) => setPageForm({ ...pageForm, access: e.target.value as 'public' | 'team' | 'admin' })}
|
|
222
|
+
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none"
|
|
223
|
+
>
|
|
224
|
+
<option value="public">Public</option>
|
|
225
|
+
<option value="team">Team</option>
|
|
226
|
+
<option value="admin">Admin</option>
|
|
227
|
+
</select>
|
|
228
|
+
<div className="flex gap-2">
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
onClick={async () => {
|
|
232
|
+
await admin.createPage({
|
|
233
|
+
title: pageForm.title,
|
|
234
|
+
slug: pageForm.slug,
|
|
235
|
+
content: pageForm.content,
|
|
236
|
+
sectionId: selectedSection.id,
|
|
237
|
+
access: pageForm.access,
|
|
238
|
+
tags: pageForm.tags ? pageForm.tags.split(',').map((t) => t.trim()) : [],
|
|
239
|
+
order: sectionPages.length,
|
|
240
|
+
})
|
|
241
|
+
setPageForm({ title: '', slug: '', content: '', access: 'team', tags: '' })
|
|
242
|
+
setShowPageForm(false)
|
|
243
|
+
}}
|
|
244
|
+
className="rounded-lg bg-indigo-600 px-4 py-2 text-xs font-medium hover:bg-indigo-500"
|
|
245
|
+
>
|
|
246
|
+
Create
|
|
247
|
+
</button>
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
onClick={() => setShowPageForm(false)}
|
|
251
|
+
className="rounded-lg border border-white/10 px-4 py-2 text-xs hover:bg-white/5"
|
|
252
|
+
>
|
|
253
|
+
Cancel
|
|
254
|
+
</button>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
{/* Pages table */}
|
|
260
|
+
{selectedSection && sectionPages.length > 0 && (
|
|
261
|
+
<div className="space-y-1">
|
|
262
|
+
{sectionPages.map((page) => (
|
|
263
|
+
<div
|
|
264
|
+
key={page.id}
|
|
265
|
+
className="group flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors hover:bg-white/5"
|
|
266
|
+
>
|
|
267
|
+
<div className="flex-1 min-w-0">
|
|
268
|
+
<p className="text-sm font-medium text-white/80 truncate">{page.title}</p>
|
|
269
|
+
<p className="text-[11px] text-white/30">/{page.slug}</p>
|
|
270
|
+
</div>
|
|
271
|
+
<span
|
|
272
|
+
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
|
273
|
+
page.status === 'published'
|
|
274
|
+
? 'bg-emerald-500/15 text-emerald-400'
|
|
275
|
+
: page.status === 'archived'
|
|
276
|
+
? 'bg-white/5 text-white/30'
|
|
277
|
+
: 'bg-amber-500/15 text-amber-400'
|
|
278
|
+
}`}
|
|
279
|
+
>
|
|
280
|
+
{page.status}
|
|
281
|
+
</span>
|
|
282
|
+
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
283
|
+
{page.status === 'draft' && (
|
|
284
|
+
<button
|
|
285
|
+
type="button"
|
|
286
|
+
onClick={() => admin.publishPage(page.id)}
|
|
287
|
+
className="rounded px-2 py-1 text-[11px] text-emerald-400 hover:bg-emerald-500/10"
|
|
288
|
+
title="Publish"
|
|
289
|
+
>
|
|
290
|
+
Publish
|
|
291
|
+
</button>
|
|
292
|
+
)}
|
|
293
|
+
{page.status === 'published' && (
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
onClick={() => admin.archivePage(page.id)}
|
|
297
|
+
className="rounded px-2 py-1 text-[11px] text-amber-400 hover:bg-amber-500/10"
|
|
298
|
+
title="Archive"
|
|
299
|
+
>
|
|
300
|
+
Archive
|
|
301
|
+
</button>
|
|
302
|
+
)}
|
|
303
|
+
<button
|
|
304
|
+
type="button"
|
|
305
|
+
onClick={() => {
|
|
306
|
+
if (confirm(`Delete page "${page.title}"?`)) admin.deletePage(page.id)
|
|
307
|
+
}}
|
|
308
|
+
className="rounded p-1 text-white/20 hover:text-red-400"
|
|
309
|
+
title="Delete"
|
|
310
|
+
>
|
|
311
|
+
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor">
|
|
312
|
+
<path d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z" />
|
|
313
|
+
<path fillRule="evenodd" d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118z" clipRule="evenodd" />
|
|
314
|
+
</svg>
|
|
315
|
+
</button>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
))}
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
|
|
322
|
+
{selectedSection && sectionPages.length === 0 && !showPageForm && (
|
|
323
|
+
<p className="py-12 text-center text-sm text-white/30">No pages in this section</p>
|
|
324
|
+
)}
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
)
|
|
330
|
+
}
|