wiki-viewer 1.0.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/.next/standalone/.github/workflows/publish.yml +37 -0
- package/.next/standalone/DESIGN.md +521 -0
- package/.next/standalone/README.md +283 -0
- package/.next/standalone/bin/wiki-viewer.js +158 -0
- package/.next/standalone/next.config.ts +11 -0
- package/.next/standalone/package.json +120 -0
- package/.next/standalone/pnpm-lock.yaml +5654 -0
- package/.next/standalone/pnpm-workspace.yaml +2 -0
- package/.next/standalone/postcss.config.mjs +2 -0
- package/.next/standalone/public/logo.svg +11 -0
- package/.next/standalone/public/public/logo.svg +11 -0
- package/.next/standalone/server.js +44 -0
- package/.next/standalone/src/app/api/app-proxy/[...path]/route.ts +247 -0
- package/.next/standalone/src/app/api/assets/[...path]/route.ts +70 -0
- package/.next/standalone/src/app/api/system/browse/route.ts +78 -0
- package/.next/standalone/src/app/api/system/clear-root/route.ts +9 -0
- package/.next/standalone/src/app/api/system/config/route.ts +10 -0
- package/.next/standalone/src/app/api/system/pins/route.ts +31 -0
- package/.next/standalone/src/app/api/system/reveal/route.ts +28 -0
- package/.next/standalone/src/app/api/system/root-status/route.ts +9 -0
- package/.next/standalone/src/app/api/system/set-root/route.ts +23 -0
- package/.next/standalone/src/app/api/upload/[...path]/route.ts +80 -0
- package/.next/standalone/src/app/api/wiki/app/route.ts +41 -0
- package/.next/standalone/src/app/api/wiki/content/route.ts +60 -0
- package/.next/standalone/src/app/api/wiki/file/route.ts +52 -0
- package/.next/standalone/src/app/api/wiki/folder/route.ts +26 -0
- package/.next/standalone/src/app/api/wiki/move/route.ts +45 -0
- package/.next/standalone/src/app/api/wiki/page/route.ts +84 -0
- package/.next/standalone/src/app/api/wiki/route.ts +98 -0
- package/.next/standalone/src/app/api/wiki/slugs/route.ts +50 -0
- package/.next/standalone/src/app/api/wiki/upload/route.ts +87 -0
- package/.next/standalone/src/app/api/wiki/watch/route.ts +86 -0
- package/.next/standalone/src/app/globals.css +716 -0
- package/.next/standalone/src/app/icon.svg +11 -0
- package/.next/standalone/src/app/layout.tsx +35 -0
- package/.next/standalone/src/app/page.tsx +1509 -0
- package/.next/standalone/src/components/confirm-dialog.tsx +56 -0
- package/.next/standalone/src/components/dir-picker.tsx +393 -0
- package/.next/standalone/src/components/editor/bubble-menu.tsx +286 -0
- package/.next/standalone/src/components/editor/callout-extension.ts +36 -0
- package/.next/standalone/src/components/editor/csv-viewer.tsx +335 -0
- package/.next/standalone/src/components/editor/editor-toolbar.tsx +564 -0
- package/.next/standalone/src/components/editor/editor.tsx +633 -0
- package/.next/standalone/src/components/editor/extensions/drag-handle.ts +325 -0
- package/.next/standalone/src/components/editor/extensions/heading-anchors.ts +55 -0
- package/.next/standalone/src/components/editor/extensions/resizable-image.tsx +159 -0
- package/.next/standalone/src/components/editor/extensions.ts +148 -0
- package/.next/standalone/src/components/editor/file-fallback-viewer.tsx +74 -0
- package/.next/standalone/src/components/editor/folder-index.tsx +235 -0
- package/.next/standalone/src/components/editor/google-doc-viewer.tsx +73 -0
- package/.next/standalone/src/components/editor/image-viewer.tsx +56 -0
- package/.next/standalone/src/components/editor/link-popover.tsx +111 -0
- package/.next/standalone/src/components/editor/media-popover.tsx +211 -0
- package/.next/standalone/src/components/editor/media-viewer.tsx +69 -0
- package/.next/standalone/src/components/editor/mermaid-viewer.tsx +285 -0
- package/.next/standalone/src/components/editor/node-app-viewer.tsx +269 -0
- package/.next/standalone/src/components/editor/notebook-viewer.tsx +370 -0
- package/.next/standalone/src/components/editor/office/docx-viewer.tsx +92 -0
- package/.next/standalone/src/components/editor/office/office-chrome.tsx +82 -0
- package/.next/standalone/src/components/editor/office/pptx-viewer.tsx +103 -0
- package/.next/standalone/src/components/editor/office/xlsx-viewer.tsx +116 -0
- package/.next/standalone/src/components/editor/pdf-viewer.tsx +31 -0
- package/.next/standalone/src/components/editor/slash-commands.tsx +454 -0
- package/.next/standalone/src/components/editor/source-viewer.tsx +212 -0
- package/.next/standalone/src/components/editor/table-menu.tsx +232 -0
- package/.next/standalone/src/components/editor/website-viewer.tsx +74 -0
- package/.next/standalone/src/components/editor/wiki-link-create-dialog.tsx +188 -0
- package/.next/standalone/src/components/editor/wiki-link-decorator.ts +79 -0
- package/.next/standalone/src/components/editor/wiki-link-extension.ts +120 -0
- package/.next/standalone/src/components/editor/wiki-link-picker.tsx +250 -0
- package/.next/standalone/src/components/layout/viewer-toolbar.tsx +54 -0
- package/.next/standalone/src/components/theme-provider.tsx +16 -0
- package/.next/standalone/src/components/theme-toggle.tsx +24 -0
- package/.next/standalone/src/components/ui/badge.tsx +35 -0
- package/.next/standalone/src/components/ui/button.tsx +56 -0
- package/.next/standalone/src/components/ui/card.tsx +63 -0
- package/.next/standalone/src/components/ui/collapsible.tsx +9 -0
- package/.next/standalone/src/components/ui/command.tsx +150 -0
- package/.next/standalone/src/components/ui/context-menu.tsx +191 -0
- package/.next/standalone/src/components/ui/dialog.tsx +119 -0
- package/.next/standalone/src/components/ui/dropdown-menu.tsx +194 -0
- package/.next/standalone/src/components/ui/input.tsx +22 -0
- package/.next/standalone/src/components/ui/label.tsx +25 -0
- package/.next/standalone/src/components/ui/popover.tsx +33 -0
- package/.next/standalone/src/components/ui/scroll-area.tsx +47 -0
- package/.next/standalone/src/components/ui/select.tsx +156 -0
- package/.next/standalone/src/components/ui/separator.tsx +30 -0
- package/.next/standalone/src/components/ui/sheet.tsx +140 -0
- package/.next/standalone/src/components/ui/skeleton.tsx +15 -0
- package/.next/standalone/src/components/ui/switch.tsx +28 -0
- package/.next/standalone/src/components/ui/tabs.tsx +54 -0
- package/.next/standalone/src/components/ui/textarea.tsx +21 -0
- package/.next/standalone/src/components/ui/tip.tsx +23 -0
- package/.next/standalone/src/components/ui/toggle-group.tsx +41 -0
- package/.next/standalone/src/components/ui/tooltip.tsx +27 -0
- package/.next/standalone/src/components/wiki/frontmatter-header.tsx +164 -0
- package/.next/standalone/src/lib/app-runner.ts +231 -0
- package/.next/standalone/src/lib/cabinets/tree.ts +75 -0
- package/.next/standalone/src/lib/config.ts +32 -0
- package/.next/standalone/src/lib/embeds/detect.ts +142 -0
- package/.next/standalone/src/lib/google/detect.ts +107 -0
- package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +84 -0
- package/.next/standalone/src/lib/markdown/remark-wikilinks.ts +100 -0
- package/.next/standalone/src/lib/markdown/to-html.ts +168 -0
- package/.next/standalone/src/lib/markdown/to-markdown.ts +238 -0
- package/.next/standalone/src/lib/root-dir.ts +35 -0
- package/.next/standalone/src/lib/toast.ts +9 -0
- package/.next/standalone/src/lib/utils.ts +6 -0
- package/.next/standalone/src/lib/wiki-helpers.ts +36 -0
- package/.next/standalone/src/stores/ai-panel-store.ts +23 -0
- package/.next/standalone/src/stores/editor-store.ts +244 -0
- package/.next/standalone/src/stores/tree-store.ts +52 -0
- package/.next/standalone/src/stores/wiki-slugs-store.ts +74 -0
- package/.next/standalone/src/types/index.ts +47 -0
- package/.next/standalone/tailwind.config.ts +201 -0
- package/.next/standalone/tsconfig.json +41 -0
- package/README.md +283 -0
- package/bin/wiki-viewer.js +158 -0
- package/package.json +120 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { getRootDir } from "@/lib/root-dir";
|
|
5
|
+
|
|
6
|
+
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
function sanitizeFilename(name: string): string {
|
|
9
|
+
const lowered = name.toLowerCase();
|
|
10
|
+
const cleaned = lowered.replace(/[^a-z0-9._-]+/g, "-").replace(/-+/g, "-");
|
|
11
|
+
const trimmed = cleaned.replace(/^[-.]+/, "").replace(/-+$/, "");
|
|
12
|
+
return trimmed || "file";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function pickAvailableName(dir: string, filename: string): Promise<string> {
|
|
16
|
+
const ext = path.extname(filename);
|
|
17
|
+
const stem = filename.slice(0, filename.length - ext.length);
|
|
18
|
+
let candidate = filename;
|
|
19
|
+
let n = 2;
|
|
20
|
+
while (true) {
|
|
21
|
+
try {
|
|
22
|
+
await stat(path.join(dir, candidate));
|
|
23
|
+
candidate = `${stem}-${n}${ext}`;
|
|
24
|
+
n += 1;
|
|
25
|
+
} catch {
|
|
26
|
+
return candidate;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function POST(
|
|
32
|
+
request: Request,
|
|
33
|
+
{ params }: { params: Promise<{ path: string[] }> },
|
|
34
|
+
) {
|
|
35
|
+
const { path: segments } = await params;
|
|
36
|
+
const subPath = (segments ?? []).join("/");
|
|
37
|
+
|
|
38
|
+
// Save images in _uploads/ within getRootDir(), mirroring the page path
|
|
39
|
+
const uploadsDir = path.join(getRootDir(), "_uploads", subPath);
|
|
40
|
+
const resolved = path.resolve(uploadsDir);
|
|
41
|
+
if (!resolved.startsWith(getRootDir() + path.sep) && resolved !== getRootDir())
|
|
42
|
+
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
|
43
|
+
|
|
44
|
+
let form: FormData;
|
|
45
|
+
try {
|
|
46
|
+
form = await request.formData();
|
|
47
|
+
} catch {
|
|
48
|
+
return NextResponse.json({ error: "Expected multipart/form-data" }, { status: 400 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const file = form.get("file");
|
|
52
|
+
if (!(file instanceof File))
|
|
53
|
+
return NextResponse.json({ error: "Missing file field" }, { status: 400 });
|
|
54
|
+
if (file.size > MAX_UPLOAD_BYTES)
|
|
55
|
+
return NextResponse.json({ error: "File exceeds 50MB limit" }, { status: 413 });
|
|
56
|
+
|
|
57
|
+
const filename = sanitizeFilename(file.name || "file");
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await mkdir(uploadsDir, { recursive: true });
|
|
61
|
+
const finalName = await pickAvailableName(uploadsDir, filename);
|
|
62
|
+
const targetPath = path.join(uploadsDir, finalName);
|
|
63
|
+
const bytes = Buffer.from(await file.arrayBuffer());
|
|
64
|
+
await writeFile(targetPath, bytes);
|
|
65
|
+
|
|
66
|
+
const relParts = ["_uploads", subPath, finalName].filter(Boolean);
|
|
67
|
+
const relUrl = relParts.map(encodeURIComponent).join("/");
|
|
68
|
+
const relPath = relParts.join("/");
|
|
69
|
+
return NextResponse.json({
|
|
70
|
+
url: `/api/assets/${relUrl}`,
|
|
71
|
+
path: relPath,
|
|
72
|
+
absolutePath: targetPath,
|
|
73
|
+
size: bytes.length,
|
|
74
|
+
mimeType: file.type || "application/octet-stream",
|
|
75
|
+
});
|
|
76
|
+
} catch (err) {
|
|
77
|
+
const message = err instanceof Error ? err.message : "Write failed";
|
|
78
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { NextResponse } from "next/server";
|
|
3
|
+
import { getStatus, startApp, stopApp } from "@/lib/app-runner";
|
|
4
|
+
import { getRootDir, safeRootPath } from "@/lib/root-dir";
|
|
5
|
+
|
|
6
|
+
// GET /api/wiki/app?path=relative/path
|
|
7
|
+
export async function GET(request: Request) {
|
|
8
|
+
const { searchParams } = new URL(request.url);
|
|
9
|
+
const rel = searchParams.get("path") ?? "";
|
|
10
|
+
return NextResponse.json(getStatus(rel));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// POST /api/wiki/app { path: "relative/path" }
|
|
14
|
+
export async function POST(request: Request) {
|
|
15
|
+
const body: { path?: string } = await request.json();
|
|
16
|
+
const rel = body.path;
|
|
17
|
+
if (!rel || typeof rel !== "string")
|
|
18
|
+
return NextResponse.json({ error: "Missing path" }, { status: 400 });
|
|
19
|
+
|
|
20
|
+
const abs = safeRootPath(rel);
|
|
21
|
+
if (!abs)
|
|
22
|
+
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const result = await startApp(rel, abs);
|
|
26
|
+
return NextResponse.json(result);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
return NextResponse.json({ error: String(e) }, { status: 500 });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// DELETE /api/wiki/app { path: "relative/path" }
|
|
33
|
+
export async function DELETE(request: Request) {
|
|
34
|
+
const body: { path?: string } = await request.json();
|
|
35
|
+
const rel = body.path;
|
|
36
|
+
if (!rel || typeof rel !== "string")
|
|
37
|
+
return NextResponse.json({ error: "Missing path" }, { status: 400 });
|
|
38
|
+
|
|
39
|
+
stopApp(rel);
|
|
40
|
+
return NextResponse.json({ ok: true });
|
|
41
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { safeRootPath } from "@/lib/root-dir";
|
|
5
|
+
|
|
6
|
+
const TEXT_EXTS = new Set([
|
|
7
|
+
"txt", "md", "markdown", "json", "yaml", "yml", "toml", "csv",
|
|
8
|
+
"xml", "html", "css", "js", "ts", "tsx", "jsx", "sh", "bash",
|
|
9
|
+
"zsh", "rb", "py", "go", "rs", "java", "c", "cpp", "h", "php",
|
|
10
|
+
"swift", "kt", "lua", "sql", "scss",
|
|
11
|
+
]);
|
|
12
|
+
const MAX_EDIT_SIZE = 1 * 1024 * 1024; // 1MB
|
|
13
|
+
|
|
14
|
+
function isTextFile(filename: string): boolean {
|
|
15
|
+
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
|
16
|
+
return TEXT_EXTS.has(ext);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function GET(request: Request) {
|
|
20
|
+
const { searchParams } = new URL(request.url);
|
|
21
|
+
const rel = searchParams.get("path") ?? "";
|
|
22
|
+
const filePath = safeRootPath(rel);
|
|
23
|
+
if (!filePath)
|
|
24
|
+
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
|
25
|
+
if (!isTextFile(path.basename(filePath)))
|
|
26
|
+
return NextResponse.json({ error: "Not a text file" }, { status: 400 });
|
|
27
|
+
try {
|
|
28
|
+
const buffer = await readFile(filePath);
|
|
29
|
+
if (buffer.length > MAX_EDIT_SIZE)
|
|
30
|
+
return NextResponse.json(
|
|
31
|
+
{ error: "File too large (max 1MB)" },
|
|
32
|
+
{ status: 413 },
|
|
33
|
+
);
|
|
34
|
+
return NextResponse.json({ content: buffer.toString("utf-8") });
|
|
35
|
+
} catch {
|
|
36
|
+
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function PUT(request: Request) {
|
|
41
|
+
const body: { path?: string; content?: string } = await request.json();
|
|
42
|
+
const rel = body.path;
|
|
43
|
+
const content = body.content;
|
|
44
|
+
if (!rel || typeof rel !== "string")
|
|
45
|
+
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
|
46
|
+
if (typeof content !== "string")
|
|
47
|
+
return NextResponse.json({ error: "Missing content" }, { status: 400 });
|
|
48
|
+
const filePath = safeRootPath(rel);
|
|
49
|
+
if (!filePath)
|
|
50
|
+
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
|
51
|
+
if (!isTextFile(path.basename(filePath)))
|
|
52
|
+
return NextResponse.json({ error: "Not a text file" }, { status: 400 });
|
|
53
|
+
try {
|
|
54
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
55
|
+
await writeFile(filePath, content, "utf-8");
|
|
56
|
+
return NextResponse.json({ ok: true });
|
|
57
|
+
} catch {
|
|
58
|
+
return NextResponse.json({ error: "Failed to save" }, { status: 500 });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { safeRootPath } from "@/lib/root-dir";
|
|
5
|
+
|
|
6
|
+
const MIME_MAP: Record<string, string> = {
|
|
7
|
+
jpg: "image/jpeg",
|
|
8
|
+
jpeg: "image/jpeg",
|
|
9
|
+
png: "image/png",
|
|
10
|
+
gif: "image/gif",
|
|
11
|
+
webp: "image/webp",
|
|
12
|
+
svg: "image/svg+xml",
|
|
13
|
+
avif: "image/avif",
|
|
14
|
+
ico: "image/x-icon",
|
|
15
|
+
bmp: "image/bmp",
|
|
16
|
+
pdf: "application/pdf",
|
|
17
|
+
txt: "text/plain; charset=utf-8",
|
|
18
|
+
md: "text/markdown; charset=utf-8",
|
|
19
|
+
mp4: "video/mp4",
|
|
20
|
+
webm: "video/webm",
|
|
21
|
+
mov: "video/quicktime",
|
|
22
|
+
m4v: "video/mp4",
|
|
23
|
+
mp3: "audio/mpeg",
|
|
24
|
+
wav: "audio/wav",
|
|
25
|
+
ogg: "audio/ogg",
|
|
26
|
+
m4a: "audio/mp4",
|
|
27
|
+
aac: "audio/aac",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export async function GET(request: Request) {
|
|
31
|
+
const { searchParams } = new URL(request.url);
|
|
32
|
+
const rel = searchParams.get("path") ?? "";
|
|
33
|
+
const filePath = safeRootPath(rel);
|
|
34
|
+
if (!filePath)
|
|
35
|
+
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
|
36
|
+
try {
|
|
37
|
+
const info = await stat(filePath);
|
|
38
|
+
if (info.isDirectory())
|
|
39
|
+
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
|
40
|
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
41
|
+
const contentType = MIME_MAP[ext] ?? "application/octet-stream";
|
|
42
|
+
const buffer = await readFile(filePath);
|
|
43
|
+
return new Response(buffer, {
|
|
44
|
+
headers: {
|
|
45
|
+
"Content-Type": contentType,
|
|
46
|
+
"Cache-Control": "private, max-age=60",
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
} catch {
|
|
50
|
+
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { NextResponse } from "next/server";
|
|
3
|
+
import { safeRootPath } from "@/lib/root-dir";
|
|
4
|
+
|
|
5
|
+
export async function POST(request: Request) {
|
|
6
|
+
const body: { path?: string } = await request.json();
|
|
7
|
+
const rel = body.path;
|
|
8
|
+
|
|
9
|
+
if (!rel || typeof rel !== "string" || /[<>:"|?*]/.test(rel)) {
|
|
10
|
+
return NextResponse.json({ error: "Invalid folder path" }, { status: 400 });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const folderPath = safeRootPath(rel);
|
|
14
|
+
if (!folderPath)
|
|
15
|
+
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
await mkdir(folderPath, { recursive: true });
|
|
19
|
+
return NextResponse.json({ ok: true });
|
|
20
|
+
} catch {
|
|
21
|
+
return NextResponse.json(
|
|
22
|
+
{ error: "Failed to create folder" },
|
|
23
|
+
{ status: 500 },
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { rename, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { safeRootPath } from "@/lib/root-dir";
|
|
5
|
+
|
|
6
|
+
export async function POST(request: Request) {
|
|
7
|
+
const body: { from?: string; to?: string } = await request.json();
|
|
8
|
+
if (
|
|
9
|
+
!body.from ||
|
|
10
|
+
!body.to ||
|
|
11
|
+
typeof body.from !== "string" ||
|
|
12
|
+
typeof body.to !== "string"
|
|
13
|
+
) {
|
|
14
|
+
return NextResponse.json(
|
|
15
|
+
{ error: "Missing from/to paths" },
|
|
16
|
+
{ status: 400 },
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const fromPath = safeRootPath(body.from);
|
|
21
|
+
const toPath = safeRootPath(body.to);
|
|
22
|
+
|
|
23
|
+
if (!fromPath || !toPath)
|
|
24
|
+
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
|
25
|
+
|
|
26
|
+
if (toPath.startsWith(fromPath + path.sep) || toPath === fromPath) {
|
|
27
|
+
return NextResponse.json(
|
|
28
|
+
{ error: "Cannot move a folder into itself" },
|
|
29
|
+
{ status: 400 },
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
await stat(fromPath);
|
|
35
|
+
} catch {
|
|
36
|
+
return NextResponse.json({ error: "Source not found" }, { status: 404 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await rename(fromPath, toPath);
|
|
41
|
+
return NextResponse.json({ ok: true });
|
|
42
|
+
} catch {
|
|
43
|
+
return NextResponse.json({ error: "Move failed" }, { status: 500 });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { safeRootPath } from "@/lib/root-dir";
|
|
5
|
+
|
|
6
|
+
const VALID_DIRS = new Set(["entities", "concepts", "comparisons"]);
|
|
7
|
+
const SLUG_RE = /^[a-z0-9-]+$/;
|
|
8
|
+
|
|
9
|
+
type PageBody = {
|
|
10
|
+
dir?: string;
|
|
11
|
+
slug?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function humanizeSlug(slug: string): string {
|
|
16
|
+
return slug
|
|
17
|
+
.split("-")
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
20
|
+
.join(" ");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function dateStampUTC(): string {
|
|
24
|
+
return new Date().toISOString().slice(0, 10);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function singularType(
|
|
28
|
+
dir: "entities" | "concepts" | "comparisons",
|
|
29
|
+
): "entity" | "concept" | "comparison" {
|
|
30
|
+
if (dir === "entities") return "entity";
|
|
31
|
+
if (dir === "concepts") return "concept";
|
|
32
|
+
return "comparison";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function POST(request: Request) {
|
|
36
|
+
const body: PageBody = await request.json();
|
|
37
|
+
const { dir, slug } = body;
|
|
38
|
+
|
|
39
|
+
if (!dir || !VALID_DIRS.has(dir)) {
|
|
40
|
+
return NextResponse.json({ error: "Invalid dir" }, { status: 400 });
|
|
41
|
+
}
|
|
42
|
+
if (!slug || !SLUG_RE.test(slug)) {
|
|
43
|
+
return NextResponse.json({ error: "Invalid slug" }, { status: 400 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const relPath = `${dir}/${slug}.md`;
|
|
47
|
+
const filePath = safeRootPath(relPath);
|
|
48
|
+
if (!filePath)
|
|
49
|
+
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await stat(filePath);
|
|
53
|
+
return NextResponse.json(
|
|
54
|
+
{ error: "Page already exists", path: relPath },
|
|
55
|
+
{ status: 409 },
|
|
56
|
+
);
|
|
57
|
+
} catch (e: unknown) {
|
|
58
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
59
|
+
if (code !== "ENOENT") {
|
|
60
|
+
return NextResponse.json(
|
|
61
|
+
{ error: "Failed to create page" },
|
|
62
|
+
{ status: 500 },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const safeDir = dir as "entities" | "concepts" | "comparisons";
|
|
68
|
+
const resolvedTitle =
|
|
69
|
+
typeof body.title === "string" && body.title.trim().length > 0
|
|
70
|
+
? body.title
|
|
71
|
+
: humanizeSlug(slug);
|
|
72
|
+
const content = `---\ntitle: ${resolvedTitle}\ntype: ${singularType(safeDir)}\ntags: []\nupdated: ${dateStampUTC()}\n---\n\n# ${resolvedTitle}\n\n`;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
76
|
+
await writeFile(filePath, content, "utf-8");
|
|
77
|
+
return NextResponse.json({ ok: true, path: relPath, slug, dir: safeDir });
|
|
78
|
+
} catch {
|
|
79
|
+
return NextResponse.json(
|
|
80
|
+
{ error: "Failed to create page" },
|
|
81
|
+
{ status: 500 },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readdir, rmdir, stat, unlink } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { getRootDir, safeRootPath } from "@/lib/root-dir";
|
|
5
|
+
import { isAppFolder, isNodeApp } from "@/lib/wiki-helpers";
|
|
6
|
+
|
|
7
|
+
export async function GET(request: Request) {
|
|
8
|
+
const { searchParams } = new URL(request.url);
|
|
9
|
+
const dir = searchParams.get("dir") ?? "";
|
|
10
|
+
|
|
11
|
+
const targetDir = safeRootPath(dir);
|
|
12
|
+
if (!targetDir)
|
|
13
|
+
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
let names: string[];
|
|
17
|
+
try {
|
|
18
|
+
names = await readdir(targetDir);
|
|
19
|
+
} catch {
|
|
20
|
+
return NextResponse.json({ entries: [] });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const entries = await Promise.all(
|
|
24
|
+
names.map(async (name) => {
|
|
25
|
+
const filePath = path.join(targetDir, name);
|
|
26
|
+
const info = await stat(filePath);
|
|
27
|
+
if (info.isDirectory()) {
|
|
28
|
+
const relPath = dir ? `${dir}/${name}` : name;
|
|
29
|
+
const nodeApp = await isNodeApp(getRootDir(), relPath);
|
|
30
|
+
if (nodeApp) {
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
type: "node-app" as const,
|
|
34
|
+
modifiedAt: info.mtime.toISOString(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const isApp = await isAppFolder(getRootDir(), relPath);
|
|
38
|
+
return {
|
|
39
|
+
name,
|
|
40
|
+
type: (isApp ? "app" : "dir") as "app" | "dir",
|
|
41
|
+
modifiedAt: info.mtime.toISOString(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
name,
|
|
46
|
+
type: "file" as const,
|
|
47
|
+
size: info.size,
|
|
48
|
+
modifiedAt: info.mtime.toISOString(),
|
|
49
|
+
};
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
entries.sort((a, b) => {
|
|
54
|
+
const aIsDir = a.type === "dir" || a.type === "app" || a.type === "node-app";
|
|
55
|
+
const bIsDir = b.type === "dir" || b.type === "app" || b.type === "node-app";
|
|
56
|
+
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
|
|
57
|
+
return a.name.localeCompare(b.name);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return NextResponse.json({ entries });
|
|
61
|
+
} catch {
|
|
62
|
+
return NextResponse.json(
|
|
63
|
+
{ error: "Failed to list directory" },
|
|
64
|
+
{ status: 500 },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function DELETE(request: Request) {
|
|
70
|
+
const body: { path?: string } = await request.json();
|
|
71
|
+
const rel = body.path;
|
|
72
|
+
if (!rel || typeof rel !== "string")
|
|
73
|
+
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
|
74
|
+
|
|
75
|
+
const filePath = safeRootPath(rel);
|
|
76
|
+
if (!filePath || filePath === getRootDir())
|
|
77
|
+
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const info = await stat(filePath);
|
|
81
|
+
if (info.isDirectory()) {
|
|
82
|
+
await rmdir(filePath);
|
|
83
|
+
} else {
|
|
84
|
+
await unlink(filePath);
|
|
85
|
+
}
|
|
86
|
+
return NextResponse.json({ ok: true });
|
|
87
|
+
} catch (e: unknown) {
|
|
88
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
89
|
+
if (code === "ENOENT")
|
|
90
|
+
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
91
|
+
if (code === "ENOTEMPTY")
|
|
92
|
+
return NextResponse.json(
|
|
93
|
+
{ error: "Folder is not empty" },
|
|
94
|
+
{ status: 409 },
|
|
95
|
+
);
|
|
96
|
+
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { getRootDir } from "@/lib/root-dir";
|
|
5
|
+
|
|
6
|
+
type SlugBuckets = {
|
|
7
|
+
entities: string[];
|
|
8
|
+
concepts: string[];
|
|
9
|
+
comparisons: string[];
|
|
10
|
+
root: string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
async function readMarkdownSlugsFromDir(dirPath: string): Promise<string[]> {
|
|
14
|
+
try {
|
|
15
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
16
|
+
return entries
|
|
17
|
+
.filter(
|
|
18
|
+
(entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"),
|
|
19
|
+
)
|
|
20
|
+
.map((entry) => entry.name.slice(0, -3))
|
|
21
|
+
.sort((a, b) => a.localeCompare(b));
|
|
22
|
+
} catch (e: unknown) {
|
|
23
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
24
|
+
if (code === "ENOENT") return [];
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function GET() {
|
|
30
|
+
try {
|
|
31
|
+
// Scan root + known dirs (entities, concepts, comparisons for wiki compat)
|
|
32
|
+
// plus any other immediate subdirectories
|
|
33
|
+
const [entities, concepts, comparisons, root] = await Promise.all([
|
|
34
|
+
readMarkdownSlugsFromDir(path.join(getRootDir(), "entities")),
|
|
35
|
+
readMarkdownSlugsFromDir(path.join(getRootDir(), "concepts")),
|
|
36
|
+
readMarkdownSlugsFromDir(path.join(getRootDir(), "comparisons")),
|
|
37
|
+
readMarkdownSlugsFromDir(getRootDir()),
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const body: SlugBuckets = { entities, concepts, comparisons, root };
|
|
41
|
+
return NextResponse.json(body, {
|
|
42
|
+
headers: { "Cache-Control": "private, max-age=10" },
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
return NextResponse.json(
|
|
46
|
+
{ error: "Failed to list slugs" },
|
|
47
|
+
{ status: 500 },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { safeRootPath } from "@/lib/root-dir";
|
|
5
|
+
|
|
6
|
+
const ALLOWED_MIME_TYPES = new Set([
|
|
7
|
+
"application/pdf",
|
|
8
|
+
"text/plain",
|
|
9
|
+
"text/markdown",
|
|
10
|
+
"application/msword",
|
|
11
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
12
|
+
"application/vnd.ms-excel",
|
|
13
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
14
|
+
"application/vnd.ms-powerpoint",
|
|
15
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
16
|
+
"image/jpeg",
|
|
17
|
+
"image/png",
|
|
18
|
+
"image/gif",
|
|
19
|
+
"image/webp",
|
|
20
|
+
"image/svg+xml",
|
|
21
|
+
"application/octet-stream",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const ALLOWED_EXTENSIONS = new Set([
|
|
25
|
+
"pdf", "txt", "md", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
|
|
26
|
+
"jpg", "jpeg", "png", "gif", "webp", "svg", "csv", "json",
|
|
27
|
+
"yaml", "yml", "xml", "html", "sh", "mp4", "webm", "mov",
|
|
28
|
+
"mp3", "wav", "ogg", "m4a", "aac", "ipynb", "mmd",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const MAX_SIZE_BYTES = 100 * 1024 * 1024; // 100MB
|
|
32
|
+
|
|
33
|
+
function sanitizeFilename(name: string): string {
|
|
34
|
+
return name.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/_{2,}/g, "_");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function POST(request: Request) {
|
|
38
|
+
let formData: FormData;
|
|
39
|
+
try {
|
|
40
|
+
formData = await request.formData();
|
|
41
|
+
} catch {
|
|
42
|
+
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const file = formData.get("file");
|
|
46
|
+
if (!file || !(file instanceof File))
|
|
47
|
+
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
|
48
|
+
|
|
49
|
+
const dir = (formData.get("dir") as string) ?? "";
|
|
50
|
+
const targetDir = safeRootPath(dir);
|
|
51
|
+
if (!targetDir)
|
|
52
|
+
return NextResponse.json({ error: "Invalid directory" }, { status: 400 });
|
|
53
|
+
|
|
54
|
+
const fileExt = file.name.split(".").pop()?.toLowerCase() ?? "";
|
|
55
|
+
if (!ALLOWED_MIME_TYPES.has(file.type) && !ALLOWED_EXTENSIONS.has(fileExt)) {
|
|
56
|
+
return NextResponse.json(
|
|
57
|
+
{ error: `File type not allowed: ${file.name}` },
|
|
58
|
+
{ status: 400 },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (file.size > MAX_SIZE_BYTES) {
|
|
63
|
+
return NextResponse.json(
|
|
64
|
+
{ error: "File exceeds 100MB limit" },
|
|
65
|
+
{ status: 400 },
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const baseName = sanitizeFilename(file.name.replace(/\.[^.]+$/, ""));
|
|
70
|
+
const savedFilename = `${baseName}.${fileExt}`;
|
|
71
|
+
const filePath = path.join(targetDir, savedFilename);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await mkdir(targetDir, { recursive: true });
|
|
75
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
76
|
+
await writeFile(filePath, buffer);
|
|
77
|
+
} catch {
|
|
78
|
+
return NextResponse.json({ error: "Failed to save file" }, { status: 500 });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const relPath = dir ? `${dir}/${savedFilename}` : savedFilename;
|
|
82
|
+
return NextResponse.json({
|
|
83
|
+
filename: savedFilename,
|
|
84
|
+
path: relPath,
|
|
85
|
+
originalName: file.name,
|
|
86
|
+
});
|
|
87
|
+
}
|