docsprout 0.1.3
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/LICENSE +21 -0
- package/README.md +224 -0
- package/dist/index.cjs +9725 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +9730 -0
- package/package.json +36 -0
- package/templates/base/config.json +18 -0
- package/templates/base/content/index.md +10 -0
- package/templates/base/generated/pages.json +2 -0
- package/templates/base/generated/search-index.json +2 -0
- package/templates/base/sidebar.json +9 -0
- package/templates/base/themes/default.json +8 -0
- package/templates/web/app/(public)/docs/[[...slug]]/loading.tsx +9 -0
- package/templates/web/app/(public)/docs/[[...slug]]/page.tsx +74 -0
- package/templates/web/app/api/config/route.ts +47 -0
- package/templates/web/app/api/publish/route.ts +82 -0
- package/templates/web/app/api/search/route.ts +11 -0
- package/templates/web/app/docs-admin/loading.tsx +9 -0
- package/templates/web/app/docs-admin/page.tsx +237 -0
- package/templates/web/app/globals.css +141 -0
- package/templates/web/app/layout.tsx +46 -0
- package/templates/web/app/page.tsx +17 -0
- package/templates/web/components/markdown-content.tsx +100 -0
- package/templates/web/components/providers.tsx +12 -0
- package/templates/web/components/rich-editor.tsx +94 -0
- package/templates/web/components/search-box.tsx +43 -0
- package/templates/web/components/sidebar.tsx +36 -0
- package/templates/web/components/toc.tsx +53 -0
- package/templates/web/lib/content.ts +41 -0
- package/templates/web/lib/db.ts +9 -0
- package/templates/web/lib/types.ts +33 -0
- package/templates/web/next-env.d.ts +5 -0
- package/templates/web/next.config.mjs +16 -0
- package/templates/web/package.json +40 -0
- package/templates/web/postcss.config.cjs +8 -0
- package/templates/web/prisma/schema.prisma +21 -0
- package/templates/web/tailwind.config.cjs +10 -0
- package/templates/web/tsconfig.json +20 -0
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "docsprout",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Plug-and-play documentation platform CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"docsprout": "dist/index.cjs"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.cjs",
|
|
10
|
+
"types": "dist/index.d.cts",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"templates"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"prebuild": "node ./scripts/sync-version.mjs && node ./scripts/sync-readme.mjs",
|
|
17
|
+
"build": "tsup",
|
|
18
|
+
"prepack": "node ./scripts/sync-version.mjs && node ./scripts/sync-readme.mjs",
|
|
19
|
+
"dev": "tsx src/index.ts",
|
|
20
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"chalk": "^5.3.0",
|
|
24
|
+
"chokidar": "^4.0.3",
|
|
25
|
+
"commander": "^12.1.0",
|
|
26
|
+
"execa": "^9.3.0",
|
|
27
|
+
"inquirer": "^14.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@docsprout/core": "0.1.0",
|
|
31
|
+
"@docsprout/shared": "0.1.0",
|
|
32
|
+
"tsup": "^8.2.4",
|
|
33
|
+
"tsx": "^4.16.5",
|
|
34
|
+
"typescript": "^5.5.4"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"projectName": "My Docsprout Site",
|
|
3
|
+
"contentRoots": [".", "packages/*", "apps/*"],
|
|
4
|
+
"ignore": [
|
|
5
|
+
"**/node_modules/**",
|
|
6
|
+
"**/dist/**",
|
|
7
|
+
"**/build/**",
|
|
8
|
+
"**/coverage/**",
|
|
9
|
+
"**/.next/**",
|
|
10
|
+
"**/.*/**"
|
|
11
|
+
],
|
|
12
|
+
"theme": "default",
|
|
13
|
+
"basePath": "/docs",
|
|
14
|
+
"adminPath": "/docs-admin",
|
|
15
|
+
"outputDir": "docsprout",
|
|
16
|
+
"includeDraftsInDev": true
|
|
17
|
+
}
|
|
18
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export default function LoadingDocs() {
|
|
2
|
+
return (
|
|
3
|
+
<main className="grid w-full grid-cols-1 gap-6 px-6 py-6 lg:grid-cols-[280px_minmax(0,1fr)_240px]">
|
|
4
|
+
<div className="h-[70vh] animate-pulse rounded-2xl bg-slate-200 dark:bg-slate-800" />
|
|
5
|
+
<div className="h-[70vh] animate-pulse rounded-2xl bg-slate-200 dark:bg-slate-800" />
|
|
6
|
+
<div className="h-[70vh] animate-pulse rounded-2xl bg-slate-200 dark:bg-slate-800" />
|
|
7
|
+
</main>
|
|
8
|
+
);
|
|
9
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { notFound, redirect } from "next/navigation";
|
|
2
|
+
import { getPageBySlug, getPublishedPages, readSidebar } from "../../../../lib/content";
|
|
3
|
+
import { Sidebar } from "../../../../components/sidebar";
|
|
4
|
+
import { Toc } from "../../../../components/toc";
|
|
5
|
+
import { MarkdownContent } from "../../../../components/markdown-content";
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
params: Promise<{ slug?: string[] }>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const extractHeadings = (content: string) => {
|
|
12
|
+
return content
|
|
13
|
+
.split("\n")
|
|
14
|
+
.filter((line) => line.startsWith("## ") || line.startsWith("### "))
|
|
15
|
+
.map((line) => {
|
|
16
|
+
const text = line.replace(/^#{2,3}\s+/, "").trim();
|
|
17
|
+
const id = text.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-");
|
|
18
|
+
return { text, id };
|
|
19
|
+
})
|
|
20
|
+
.slice(0, 12);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function generateStaticParams() {
|
|
24
|
+
const pages = await getPublishedPages();
|
|
25
|
+
return pages.map((page) => ({ slug: page.slug.split("/") }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default async function DocPage({ params }: Props) {
|
|
29
|
+
const route = await params;
|
|
30
|
+
const slug = route.slug?.join("/") ?? "index";
|
|
31
|
+
const page = await getPageBySlug(slug);
|
|
32
|
+
|
|
33
|
+
if (!page) {
|
|
34
|
+
if (slug === "index") {
|
|
35
|
+
const allPages = await getPublishedPages();
|
|
36
|
+
if (allPages.length > 0) {
|
|
37
|
+
redirect(`/docs/${allPages[0].slug}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
notFound();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const allPages = await getPublishedPages();
|
|
44
|
+
const index = allPages.findIndex((p) => p.slug === page.slug);
|
|
45
|
+
const prev = index > 0 ? allPages[index - 1] : null;
|
|
46
|
+
const next = index < allPages.length - 1 ? allPages[index + 1] : null;
|
|
47
|
+
const sidebar = await readSidebar();
|
|
48
|
+
const headings = extractHeadings(page.content);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<main className="grid w-full grid-cols-1 gap-6 px-6 py-6 lg:grid-cols-[280px_minmax(0,1fr)_240px]">
|
|
52
|
+
<Sidebar items={sidebar} />
|
|
53
|
+
|
|
54
|
+
<article className="min-w-0 rounded-2xl border border-slate-200 bg-white/85 p-8 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-950/70">
|
|
55
|
+
<p className="text-xs uppercase tracking-[0.12em] text-slate-500">{page.slug}</p>
|
|
56
|
+
<h1 className="mt-2 text-3xl font-semibold tracking-tight text-slate-950 dark:text-slate-100">{page.title}</h1>
|
|
57
|
+
|
|
58
|
+
<div className="mt-8 markdown-content">
|
|
59
|
+
<MarkdownContent content={page.content} />
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div className="mt-10 grid grid-cols-1 gap-3 border-t border-slate-200 pt-5 text-sm dark:border-slate-800 sm:grid-cols-2">
|
|
63
|
+
<span>{prev ? <a className="text-slate-600 hover:text-slate-950 dark:text-slate-300 dark:hover:text-white" href={`/docs/${prev.slug}`}>Previous: {prev.title}</a> : ""}</span>
|
|
64
|
+
<span className="sm:text-right">{next ? <a className="text-slate-600 hover:text-slate-950 dark:text-slate-300 dark:hover:text-white" href={`/docs/${next.slug}`}>Next: {next.title}</a> : ""}</span>
|
|
65
|
+
</div>
|
|
66
|
+
</article>
|
|
67
|
+
|
|
68
|
+
<aside className="hidden lg:block">
|
|
69
|
+
<Toc headings={headings} />
|
|
70
|
+
</aside>
|
|
71
|
+
</main>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
|
|
5
|
+
const resolveDocsRoot = async () => {
|
|
6
|
+
let current = process.cwd();
|
|
7
|
+
while (true) {
|
|
8
|
+
const candidate = path.join(current, "docsprout");
|
|
9
|
+
try {
|
|
10
|
+
await fs.access(candidate);
|
|
11
|
+
return current;
|
|
12
|
+
} catch {
|
|
13
|
+
const parent = path.dirname(current);
|
|
14
|
+
if (parent === current) return process.cwd();
|
|
15
|
+
current = parent;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const configPath = async () => {
|
|
21
|
+
const root = await resolveDocsRoot();
|
|
22
|
+
return path.join(root, "docsprout", "config.json");
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function GET() {
|
|
26
|
+
const file = await configPath();
|
|
27
|
+
const raw = await fs.readFile(file, "utf-8");
|
|
28
|
+
return NextResponse.json({ config: JSON.parse(raw) });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function POST(request: Request) {
|
|
32
|
+
const payload = await request.json();
|
|
33
|
+
const file = await configPath();
|
|
34
|
+
const raw = await fs.readFile(file, "utf-8");
|
|
35
|
+
const existing = JSON.parse(raw) as Record<string, unknown>;
|
|
36
|
+
const next = {
|
|
37
|
+
...existing,
|
|
38
|
+
projectName: payload.projectName,
|
|
39
|
+
basePath: payload.basePath,
|
|
40
|
+
adminPath: payload.adminPath,
|
|
41
|
+
theme: payload.theme,
|
|
42
|
+
includeDraftsInDev: Boolean(payload.includeDraftsInDev)
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
await fs.writeFile(file, JSON.stringify(next, null, 2));
|
|
46
|
+
return NextResponse.json({ config: next });
|
|
47
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { prisma } from "../../../lib/db";
|
|
5
|
+
|
|
6
|
+
type GeneratedPage = {
|
|
7
|
+
title: string;
|
|
8
|
+
slug: string;
|
|
9
|
+
content: string;
|
|
10
|
+
status?: "draft" | "published";
|
|
11
|
+
metadata?: Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const resolveDocsRoot = async () => {
|
|
15
|
+
let current = process.cwd();
|
|
16
|
+
while (true) {
|
|
17
|
+
const candidate = path.join(current, "docsprout");
|
|
18
|
+
try {
|
|
19
|
+
await fs.access(candidate);
|
|
20
|
+
return current;
|
|
21
|
+
} catch {
|
|
22
|
+
const parent = path.dirname(current);
|
|
23
|
+
if (parent === current) return process.cwd();
|
|
24
|
+
current = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const syncGeneratedPages = async () => {
|
|
30
|
+
const root = await resolveDocsRoot();
|
|
31
|
+
const generatedPath = path.join(root, "docsprout", "generated", "pages.json");
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const raw = await fs.readFile(generatedPath, "utf-8");
|
|
35
|
+
const pages = JSON.parse(raw) as GeneratedPage[];
|
|
36
|
+
|
|
37
|
+
for (const page of pages) {
|
|
38
|
+
const exists = await prisma.page.findUnique({ where: { slug: page.slug }, select: { id: true } });
|
|
39
|
+
if (exists) continue;
|
|
40
|
+
|
|
41
|
+
await prisma.page.create({
|
|
42
|
+
data: {
|
|
43
|
+
title: page.title,
|
|
44
|
+
slug: page.slug,
|
|
45
|
+
content: page.content,
|
|
46
|
+
status: page.status ?? "published",
|
|
47
|
+
metadata: JSON.stringify(page.metadata ?? {})
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Ignore when generated pages are not available yet.
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export async function GET() {
|
|
57
|
+
await syncGeneratedPages();
|
|
58
|
+
const pages = await prisma.page.findMany({ orderBy: [{ status: "asc" }, { updatedAt: "desc" }] });
|
|
59
|
+
return NextResponse.json({ pages });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function POST(request: Request) {
|
|
63
|
+
const payload = await request.json();
|
|
64
|
+
const page = await prisma.page.upsert({
|
|
65
|
+
where: { slug: payload.slug },
|
|
66
|
+
create: {
|
|
67
|
+
title: payload.title,
|
|
68
|
+
slug: payload.slug,
|
|
69
|
+
content: payload.content,
|
|
70
|
+
status: payload.status,
|
|
71
|
+
metadata: payload.metadata ?? "{}"
|
|
72
|
+
},
|
|
73
|
+
update: {
|
|
74
|
+
title: payload.title,
|
|
75
|
+
content: payload.content,
|
|
76
|
+
status: payload.status,
|
|
77
|
+
metadata: payload.metadata ?? "{}"
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return NextResponse.json({ page });
|
|
82
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export async function GET() {
|
|
6
|
+
const file = path.join(process.cwd(), "docsprout", "generated", "search-index.json");
|
|
7
|
+
const raw = await fs.readFile(file, "utf-8");
|
|
8
|
+
return NextResponse.json(JSON.parse(raw));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export default function LoadingAdmin() {
|
|
2
|
+
return (
|
|
3
|
+
<main className="grid w-full grid-cols-1 gap-6 px-6 py-6 lg:grid-cols-[320px_minmax(0,1fr)_320px]">
|
|
4
|
+
<div className="h-[70vh] animate-pulse rounded-2xl bg-slate-200 dark:bg-slate-800" />
|
|
5
|
+
<div className="h-[70vh] animate-pulse rounded-2xl bg-slate-200 dark:bg-slate-800" />
|
|
6
|
+
<div className="h-[70vh] animate-pulse rounded-2xl bg-slate-200 dark:bg-slate-800" />
|
|
7
|
+
</main>
|
|
8
|
+
);
|
|
9
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { RichEditor } from "../../components/rich-editor";
|
|
5
|
+
|
|
6
|
+
type PageRecord = {
|
|
7
|
+
id: number;
|
|
8
|
+
title: string;
|
|
9
|
+
slug: string;
|
|
10
|
+
content: string;
|
|
11
|
+
status: "draft" | "published";
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type DocsConfig = {
|
|
15
|
+
projectName: string;
|
|
16
|
+
basePath: string;
|
|
17
|
+
adminPath: string;
|
|
18
|
+
theme: string;
|
|
19
|
+
includeDraftsInDev: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const themes = ["default", "modern", "minimal", "classic", "ocean", "forest", "midnight"];
|
|
23
|
+
|
|
24
|
+
const blankPage: PageRecord = {
|
|
25
|
+
id: 0,
|
|
26
|
+
title: "New Page",
|
|
27
|
+
slug: "new-page",
|
|
28
|
+
content: "# New Page\n\nStart writing...",
|
|
29
|
+
status: "draft"
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const configDefaults: DocsConfig = {
|
|
33
|
+
projectName: "My Docsprout Site",
|
|
34
|
+
basePath: "/docs",
|
|
35
|
+
adminPath: "/docs-admin",
|
|
36
|
+
theme: "default",
|
|
37
|
+
includeDraftsInDev: true
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export default function AdminPage() {
|
|
41
|
+
const [pages, setPages] = useState<PageRecord[]>([]);
|
|
42
|
+
const [active, setActive] = useState<PageRecord | null>(null);
|
|
43
|
+
const [query, setQuery] = useState("");
|
|
44
|
+
const [saving, setSaving] = useState(false);
|
|
45
|
+
const [loading, setLoading] = useState(true);
|
|
46
|
+
const [config, setConfig] = useState<DocsConfig>(configDefaults);
|
|
47
|
+
const [configSaving, setConfigSaving] = useState(false);
|
|
48
|
+
|
|
49
|
+
const load = async () => {
|
|
50
|
+
setLoading(true);
|
|
51
|
+
const [pagesRes, configRes] = await Promise.all([fetch("/api/publish"), fetch("/api/config")]);
|
|
52
|
+
const pagesData = await pagesRes.json();
|
|
53
|
+
const configData = await configRes.json();
|
|
54
|
+
|
|
55
|
+
setPages(pagesData.pages ?? []);
|
|
56
|
+
setConfig({ ...configDefaults, ...(configData.config ?? {}) });
|
|
57
|
+
|
|
58
|
+
if (!active && pagesData.pages?.length > 0) {
|
|
59
|
+
setActive(pagesData.pages[0]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setLoading(false);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
void load();
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const filtered = useMemo(() => {
|
|
70
|
+
const q = query.trim().toLowerCase();
|
|
71
|
+
if (!q) return pages;
|
|
72
|
+
return pages.filter((page) => `${page.title} ${page.slug}`.toLowerCase().includes(q));
|
|
73
|
+
}, [pages, query]);
|
|
74
|
+
|
|
75
|
+
const savePage = async () => {
|
|
76
|
+
if (!active) return;
|
|
77
|
+
setSaving(true);
|
|
78
|
+
await fetch("/api/publish", {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "Content-Type": "application/json" },
|
|
81
|
+
body: JSON.stringify(active)
|
|
82
|
+
});
|
|
83
|
+
setSaving(false);
|
|
84
|
+
await load();
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const saveConfig = async () => {
|
|
88
|
+
setConfigSaving(true);
|
|
89
|
+
await fetch("/api/config", {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { "Content-Type": "application/json" },
|
|
92
|
+
body: JSON.stringify(config)
|
|
93
|
+
});
|
|
94
|
+
setConfigSaving(false);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<main className="min-h-screen bg-gradient-to-b from-white to-slate-50 dark:from-slate-950 dark:to-slate-900">
|
|
99
|
+
<section className="grid h-[calc(100vh-146px)] w-full grid-cols-1 gap-6 px-6 pb-4 pt-6 lg:grid-cols-[320px_minmax(0,1fr)_320px]">
|
|
100
|
+
<aside className="flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-950/70">
|
|
101
|
+
<div className="mb-3 flex items-center justify-between gap-2">
|
|
102
|
+
<input
|
|
103
|
+
value={query}
|
|
104
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
105
|
+
placeholder="Search pages"
|
|
106
|
+
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
|
|
107
|
+
/>
|
|
108
|
+
<button
|
|
109
|
+
onClick={() => setActive(blankPage)}
|
|
110
|
+
className="rounded-lg border border-slate-300 px-3 py-2 text-xs font-medium hover:bg-slate-100 dark:border-slate-700 dark:hover:bg-slate-800"
|
|
111
|
+
>
|
|
112
|
+
New
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
|
117
|
+
{loading ? (
|
|
118
|
+
<div className="space-y-2">
|
|
119
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
120
|
+
<div key={i} className="h-12 animate-pulse rounded-lg bg-slate-200 dark:bg-slate-800" />
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
) : (
|
|
124
|
+
<ul className="space-y-2">
|
|
125
|
+
{filtered.map((page) => {
|
|
126
|
+
const selected = active?.slug === page.slug;
|
|
127
|
+
return (
|
|
128
|
+
<li key={page.id}>
|
|
129
|
+
<button
|
|
130
|
+
onClick={() => setActive(page)}
|
|
131
|
+
className={`w-full rounded-xl border px-3 py-2 text-left transition ${selected ? "border-slate-900 bg-slate-900 text-white dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900" : "border-slate-200 hover:bg-slate-100 dark:border-slate-800 dark:hover:bg-slate-800"}`}
|
|
132
|
+
>
|
|
133
|
+
<div className="text-sm font-semibold">{page.title}</div>
|
|
134
|
+
<div className={`text-xs ${selected ? "text-slate-200 dark:text-slate-600" : "text-slate-500"}`}>{page.slug} | {page.status}</div>
|
|
135
|
+
</button>
|
|
136
|
+
</li>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</ul>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
</aside>
|
|
143
|
+
|
|
144
|
+
<section className="flex h-full min-w-0 flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white/80 p-6 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-950/70">
|
|
145
|
+
<div className="mb-4 flex items-center justify-between">
|
|
146
|
+
<div>
|
|
147
|
+
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">docsprout cms</p>
|
|
148
|
+
<h1 className="text-2xl font-semibold tracking-tight text-slate-950 dark:text-slate-100">Page Editor</h1>
|
|
149
|
+
</div>
|
|
150
|
+
<button
|
|
151
|
+
onClick={savePage}
|
|
152
|
+
disabled={!active || saving}
|
|
153
|
+
className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 disabled:opacity-50 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300"
|
|
154
|
+
>
|
|
155
|
+
{saving ? "Saving..." : "Save Page"}
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{loading ? (
|
|
160
|
+
<div className="space-y-3">
|
|
161
|
+
<div className="h-10 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
|
|
162
|
+
<div className="h-10 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
|
|
163
|
+
<div className="h-80 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
|
|
164
|
+
</div>
|
|
165
|
+
) : !active ? (
|
|
166
|
+
<p className="text-sm text-slate-500">Select a page to edit.</p>
|
|
167
|
+
) : (
|
|
168
|
+
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
|
|
169
|
+
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
170
|
+
<label className="block text-sm">
|
|
171
|
+
<span className="mb-1 block text-slate-600 dark:text-slate-300">Title</span>
|
|
172
|
+
<input value={active.title} onChange={(e) => setActive({ ...active, title: e.target.value })} className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 dark:border-slate-700 dark:bg-slate-900" />
|
|
173
|
+
</label>
|
|
174
|
+
<label className="block text-sm">
|
|
175
|
+
<span className="mb-1 block text-slate-600 dark:text-slate-300">Slug</span>
|
|
176
|
+
<input value={active.slug} onChange={(e) => setActive({ ...active, slug: e.target.value })} className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 dark:border-slate-700 dark:bg-slate-900" />
|
|
177
|
+
</label>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<label className="block text-sm">
|
|
181
|
+
<span className="mb-1 block text-slate-600 dark:text-slate-300">Status</span>
|
|
182
|
+
<select value={active.status} onChange={(e) => setActive({ ...active, status: e.target.value as "draft" | "published" })} className="rounded-lg border border-slate-300 bg-white px-3 py-2 dark:border-slate-700 dark:bg-slate-900">
|
|
183
|
+
<option value="draft">Draft</option>
|
|
184
|
+
<option value="published">Published</option>
|
|
185
|
+
</select>
|
|
186
|
+
</label>
|
|
187
|
+
|
|
188
|
+
<div className="min-h-0 flex-1">
|
|
189
|
+
<p className="mb-1 text-sm text-slate-600 dark:text-slate-300">Content</p>
|
|
190
|
+
<RichEditor value={active.content} onChange={(content) => setActive({ ...active, content })} />
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</section>
|
|
195
|
+
|
|
196
|
+
<aside className="flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-950/70">
|
|
197
|
+
<div className="mb-3 flex items-center justify-between">
|
|
198
|
+
<h2 className="text-sm font-semibold uppercase tracking-[0.14em] text-slate-500">Project Config</h2>
|
|
199
|
+
<button
|
|
200
|
+
onClick={saveConfig}
|
|
201
|
+
className="rounded-lg border border-slate-300 px-3 py-2 text-xs font-medium hover:bg-slate-100 dark:border-slate-700 dark:hover:bg-slate-800"
|
|
202
|
+
>
|
|
203
|
+
{configSaving ? "Saving..." : "Save"}
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1 text-sm">
|
|
208
|
+
<label className="block">
|
|
209
|
+
<span className="mb-1 block text-slate-600 dark:text-slate-300">Project Name</span>
|
|
210
|
+
<input className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 dark:border-slate-700 dark:bg-slate-900" value={config.projectName} onChange={(e) => setConfig({ ...config, projectName: e.target.value })} />
|
|
211
|
+
</label>
|
|
212
|
+
<label className="block">
|
|
213
|
+
<span className="mb-1 block text-slate-600 dark:text-slate-300">Docs Base Path</span>
|
|
214
|
+
<input className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 dark:border-slate-700 dark:bg-slate-900" value={config.basePath} onChange={(e) => setConfig({ ...config, basePath: e.target.value })} />
|
|
215
|
+
</label>
|
|
216
|
+
<label className="block">
|
|
217
|
+
<span className="mb-1 block text-slate-600 dark:text-slate-300">Admin Path</span>
|
|
218
|
+
<input className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 dark:border-slate-700 dark:bg-slate-900" value={config.adminPath} onChange={(e) => setConfig({ ...config, adminPath: e.target.value })} />
|
|
219
|
+
</label>
|
|
220
|
+
<label className="block">
|
|
221
|
+
<span className="mb-1 block text-slate-600 dark:text-slate-300">Theme</span>
|
|
222
|
+
<select className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 dark:border-slate-700 dark:bg-slate-900" value={config.theme} onChange={(e) => setConfig({ ...config, theme: e.target.value })}>
|
|
223
|
+
{themes.map((theme) => (
|
|
224
|
+
<option key={theme} value={theme}>{theme}</option>
|
|
225
|
+
))}
|
|
226
|
+
</select>
|
|
227
|
+
</label>
|
|
228
|
+
<label className="flex items-center gap-2">
|
|
229
|
+
<input type="checkbox" checked={config.includeDraftsInDev} onChange={(e) => setConfig({ ...config, includeDraftsInDev: e.target.checked })} />
|
|
230
|
+
<span className="text-slate-600 dark:text-slate-300">Include drafts in dev</span>
|
|
231
|
+
</label>
|
|
232
|
+
</div>
|
|
233
|
+
</aside>
|
|
234
|
+
</section>
|
|
235
|
+
</main>
|
|
236
|
+
);
|
|
237
|
+
}
|