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,35 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
// Use globalThis so the value persists across Next.js hot-reloads in dev.
|
|
4
|
+
const g = globalThis as typeof globalThis & { __wikiRootDir?: string };
|
|
5
|
+
|
|
6
|
+
// Initialise once from env on first import.
|
|
7
|
+
if (!g.__wikiRootDir && process.env.ROOT_DIR) {
|
|
8
|
+
g.__wikiRootDir = path.resolve(process.env.ROOT_DIR);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getRootDir(): string {
|
|
12
|
+
return g.__wikiRootDir ?? "";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isRootDirSet(): boolean {
|
|
16
|
+
return !!g.__wikiRootDir;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setRootDir(dir: string): void {
|
|
20
|
+
g.__wikiRootDir = path.resolve(dir);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function clearRootDir(): void {
|
|
24
|
+
g.__wikiRootDir = undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Returns the absolute path for `rel`, or null on traversal / root not set. */
|
|
28
|
+
export function safeRootPath(rel: string): string | null {
|
|
29
|
+
const root = getRootDir();
|
|
30
|
+
if (!root) return null;
|
|
31
|
+
if (!rel || rel === ".") return root;
|
|
32
|
+
const resolved = path.resolve(root, rel);
|
|
33
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
|
|
34
|
+
return resolved;
|
|
35
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ExternalToast, toast } from "sonner";
|
|
2
|
+
|
|
3
|
+
export function showSuccess(message: string, options?: ExternalToast) {
|
|
4
|
+
toast.success(message, options);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function showError(message: string, options?: ExternalToast) {
|
|
8
|
+
toast.error(message, options);
|
|
9
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns true if the directory has a package.json — treated as a runnable node app.
|
|
6
|
+
* Checked before isAppFolder so package.json wins over index.html.
|
|
7
|
+
*/
|
|
8
|
+
export async function isNodeApp(
|
|
9
|
+
wikiDir: string,
|
|
10
|
+
relPath: string,
|
|
11
|
+
): Promise<boolean> {
|
|
12
|
+
try {
|
|
13
|
+
const pkgJson = path.join(wikiDir, relPath, "package.json");
|
|
14
|
+
const info = await stat(pkgJson);
|
|
15
|
+
return info.isFile();
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns true if `<wikiDir>/<relPath>/index.html` exists and is a regular file.
|
|
23
|
+
* Used server-side to classify a directory as a self-contained static app folder.
|
|
24
|
+
*/
|
|
25
|
+
export async function isAppFolder(
|
|
26
|
+
wikiDir: string,
|
|
27
|
+
relPath: string,
|
|
28
|
+
): Promise<boolean> {
|
|
29
|
+
try {
|
|
30
|
+
const indexHtml = path.join(wikiDir, relPath, "index.html");
|
|
31
|
+
const info = await stat(indexHtml);
|
|
32
|
+
return info.isFile();
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { create } from "zustand";
|
|
3
|
+
|
|
4
|
+
// Minimal stub. The editor reads open/clearMessages/open from this store.
|
|
5
|
+
// Extend as needed when an AI panel is added to ccmc.
|
|
6
|
+
|
|
7
|
+
interface AIPanelState {
|
|
8
|
+
isOpen: boolean;
|
|
9
|
+
open: () => void;
|
|
10
|
+
close: () => void;
|
|
11
|
+
toggle: () => void;
|
|
12
|
+
clearMessages: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const useAIPanelStore = create<AIPanelState>((set) => ({
|
|
16
|
+
isOpen: false,
|
|
17
|
+
open: () => set({ isOpen: true }),
|
|
18
|
+
close: () => set({ isOpen: false }),
|
|
19
|
+
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
|
|
20
|
+
clearMessages: () => {
|
|
21
|
+
/* no-op: no messages in stub */
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import type { FrontMatter, SaveStatus } from "@/types";
|
|
3
|
+
|
|
4
|
+
// Adapter around ccmc's /api/wiki/content endpoints.
|
|
5
|
+
// The API returns { content: string } and accepts { path, content }.
|
|
6
|
+
// We model frontmatter as null since ccmc doesn't expose it from the API.
|
|
7
|
+
|
|
8
|
+
export class FetchPageError extends Error {
|
|
9
|
+
constructor(
|
|
10
|
+
message: string,
|
|
11
|
+
public readonly status: number,
|
|
12
|
+
) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "FetchPageError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface PageData {
|
|
19
|
+
path: string;
|
|
20
|
+
content: string;
|
|
21
|
+
frontmatter: FrontMatter | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function fetchPageFromApi(path: string): Promise<PageData> {
|
|
25
|
+
const res = await fetch(`/api/wiki/content?path=${encodeURIComponent(path)}`);
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
throw new FetchPageError(`Failed to fetch page: ${path}`, res.status);
|
|
28
|
+
}
|
|
29
|
+
const data: { content: string } = await res.json();
|
|
30
|
+
return { path, content: data.content, frontmatter: null };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function savePageToApi(path: string, content: string): Promise<void> {
|
|
34
|
+
const res = await fetch("/api/wiki/content", {
|
|
35
|
+
method: "PUT",
|
|
36
|
+
headers: { "Content-Type": "application/json" },
|
|
37
|
+
body: JSON.stringify({ path, content }),
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
throw new Error("Failed to save page");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function createPageInApi(path: string, content = ""): Promise<void> {
|
|
45
|
+
// ccmc has no dedicated create endpoint; saving empty content creates the file.
|
|
46
|
+
await savePageToApi(path, content);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const PAGE_CACHE_KEY = "kb-page-cache";
|
|
50
|
+
|
|
51
|
+
interface CachedPage {
|
|
52
|
+
path: string;
|
|
53
|
+
content: string;
|
|
54
|
+
frontmatter: FrontMatter | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadCachedPage(path: string): CachedPage | null {
|
|
58
|
+
if (typeof window === "undefined") return null;
|
|
59
|
+
try {
|
|
60
|
+
const raw = localStorage.getItem(PAGE_CACHE_KEY);
|
|
61
|
+
if (!raw) return null;
|
|
62
|
+
const parsed = JSON.parse(raw) as CachedPage;
|
|
63
|
+
if (parsed.path !== path) return null;
|
|
64
|
+
return parsed;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function saveCachedPage(page: CachedPage) {
|
|
71
|
+
if (typeof window === "undefined") return;
|
|
72
|
+
try {
|
|
73
|
+
localStorage.setItem(PAGE_CACHE_KEY, JSON.stringify(page));
|
|
74
|
+
} catch {
|
|
75
|
+
// quota errors are non-fatal
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type LoadStatus = "idle" | "loading" | "ok" | "missing" | "error";
|
|
80
|
+
|
|
81
|
+
interface EditorState {
|
|
82
|
+
currentPath: string | null;
|
|
83
|
+
content: string;
|
|
84
|
+
frontmatter: FrontMatter | null;
|
|
85
|
+
saveStatus: SaveStatus;
|
|
86
|
+
loadStatus: LoadStatus;
|
|
87
|
+
isDirty: boolean;
|
|
88
|
+
isLoading: boolean;
|
|
89
|
+
lastSavedAt: number | null;
|
|
90
|
+
|
|
91
|
+
loadPage: (path: string) => Promise<void>;
|
|
92
|
+
updateContent: (content: string) => void;
|
|
93
|
+
updateFrontmatter: (updates: Partial<FrontMatter>) => void;
|
|
94
|
+
save: () => Promise<void>;
|
|
95
|
+
createMissingPage: (title: string) => Promise<void>;
|
|
96
|
+
clear: () => void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
100
|
+
let statusTimer: ReturnType<typeof setTimeout> | null = null;
|
|
101
|
+
|
|
102
|
+
export const useEditorStore = create<EditorState>((set, get) => ({
|
|
103
|
+
currentPath: null,
|
|
104
|
+
content: "",
|
|
105
|
+
frontmatter: null,
|
|
106
|
+
saveStatus: "idle",
|
|
107
|
+
loadStatus: "idle",
|
|
108
|
+
isDirty: false,
|
|
109
|
+
isLoading: false,
|
|
110
|
+
lastSavedAt: null,
|
|
111
|
+
|
|
112
|
+
loadPage: async (path: string) => {
|
|
113
|
+
const currentState = get();
|
|
114
|
+
// If we're switching to a different page, save any pending changes first.
|
|
115
|
+
if (
|
|
116
|
+
currentState.isDirty &&
|
|
117
|
+
currentState.currentPath &&
|
|
118
|
+
currentState.currentPath !== path
|
|
119
|
+
) {
|
|
120
|
+
await get().save();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
set({
|
|
124
|
+
currentPath: path,
|
|
125
|
+
isLoading: true,
|
|
126
|
+
loadStatus: "loading",
|
|
127
|
+
isDirty: false,
|
|
128
|
+
content: "",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Paint from cache immediately so the editor feels instant.
|
|
132
|
+
const cached = loadCachedPage(path);
|
|
133
|
+
if (cached) {
|
|
134
|
+
set({
|
|
135
|
+
content: cached.content,
|
|
136
|
+
frontmatter: cached.frontmatter,
|
|
137
|
+
isLoading: false,
|
|
138
|
+
loadStatus: "ok",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const page = await fetchPageFromApi(path);
|
|
144
|
+
// A newer loadPage() call may have superseded us.
|
|
145
|
+
if (get().currentPath !== path) return;
|
|
146
|
+
set({
|
|
147
|
+
content: page.content,
|
|
148
|
+
frontmatter: page.frontmatter,
|
|
149
|
+
isLoading: false,
|
|
150
|
+
loadStatus: "ok",
|
|
151
|
+
});
|
|
152
|
+
saveCachedPage({
|
|
153
|
+
path,
|
|
154
|
+
content: page.content,
|
|
155
|
+
frontmatter: page.frontmatter,
|
|
156
|
+
});
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (get().currentPath !== path) return;
|
|
159
|
+
if (err instanceof FetchPageError && err.status === 404) {
|
|
160
|
+
set({ isLoading: false, loadStatus: "missing", content: "" });
|
|
161
|
+
} else {
|
|
162
|
+
// Keep cached content visible; mark as error.
|
|
163
|
+
set({ isLoading: false, loadStatus: "error" });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
updateContent: (content: string) => {
|
|
169
|
+
set({ content, isDirty: true });
|
|
170
|
+
|
|
171
|
+
// Auto-save after 500 ms of inactivity.
|
|
172
|
+
if (saveTimer) clearTimeout(saveTimer);
|
|
173
|
+
saveTimer = setTimeout(() => {
|
|
174
|
+
get().save();
|
|
175
|
+
}, 500);
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
updateFrontmatter: (updates: Partial<FrontMatter>) => {
|
|
179
|
+
const current = get().frontmatter;
|
|
180
|
+
set({
|
|
181
|
+
frontmatter: { ...current, ...updates } as FrontMatter,
|
|
182
|
+
isDirty: true,
|
|
183
|
+
});
|
|
184
|
+
if (saveTimer) clearTimeout(saveTimer);
|
|
185
|
+
saveTimer = setTimeout(() => {
|
|
186
|
+
get().save();
|
|
187
|
+
}, 500);
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
save: async () => {
|
|
191
|
+
const { currentPath, content, isDirty } = get();
|
|
192
|
+
if (!currentPath || !isDirty) return;
|
|
193
|
+
|
|
194
|
+
set({ saveStatus: "saving" });
|
|
195
|
+
try {
|
|
196
|
+
await savePageToApi(currentPath, content);
|
|
197
|
+
set({ saveStatus: "saved", isDirty: false, lastSavedAt: Date.now() });
|
|
198
|
+
saveCachedPage({
|
|
199
|
+
path: currentPath,
|
|
200
|
+
content,
|
|
201
|
+
frontmatter: get().frontmatter,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (statusTimer) clearTimeout(statusTimer);
|
|
205
|
+
statusTimer = setTimeout(() => {
|
|
206
|
+
if (get().saveStatus === "saved") set({ saveStatus: "idle" });
|
|
207
|
+
}, 2000);
|
|
208
|
+
} catch {
|
|
209
|
+
set({ saveStatus: "error" });
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
createMissingPage: async (title: string) => {
|
|
214
|
+
const { currentPath } = get();
|
|
215
|
+
if (!currentPath) return;
|
|
216
|
+
const initialContent = `# ${title}\n\n`;
|
|
217
|
+
try {
|
|
218
|
+
await createPageInApi(`${currentPath}/index.md`, initialContent);
|
|
219
|
+
set({ content: initialContent, isDirty: false, loadStatus: "ok" });
|
|
220
|
+
saveCachedPage({
|
|
221
|
+
path: currentPath,
|
|
222
|
+
content: initialContent,
|
|
223
|
+
frontmatter: null,
|
|
224
|
+
});
|
|
225
|
+
} catch {
|
|
226
|
+
// silently ignore — user can retry
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
clear: () => {
|
|
231
|
+
if (saveTimer) clearTimeout(saveTimer);
|
|
232
|
+
if (statusTimer) clearTimeout(statusTimer);
|
|
233
|
+
set({
|
|
234
|
+
currentPath: null,
|
|
235
|
+
content: "",
|
|
236
|
+
frontmatter: null,
|
|
237
|
+
saveStatus: "idle",
|
|
238
|
+
loadStatus: "idle",
|
|
239
|
+
isDirty: false,
|
|
240
|
+
isLoading: false,
|
|
241
|
+
lastSavedAt: null,
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
}));
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { create } from "zustand";
|
|
3
|
+
import type { TreeNode } from "@/types";
|
|
4
|
+
|
|
5
|
+
// Minimal stub used by folder-index.tsx navigation callbacks.
|
|
6
|
+
// The editor's folder navigation (selectPage, expandPath) is wired here.
|
|
7
|
+
// ccmc doesn't have a global tree sidebar, so these are local no-ops
|
|
8
|
+
// that still satisfy the type contract.
|
|
9
|
+
|
|
10
|
+
interface TreeState {
|
|
11
|
+
nodes: TreeNode[];
|
|
12
|
+
selectedPath: string | null;
|
|
13
|
+
expandedPaths: Set<string>;
|
|
14
|
+
|
|
15
|
+
loadTree: () => Promise<void>;
|
|
16
|
+
selectPage: (path: string | null) => void;
|
|
17
|
+
expandPath: (path: string) => void;
|
|
18
|
+
focusPath: (path: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const useTreeStore = create<TreeState>((set, get) => ({
|
|
22
|
+
nodes: [],
|
|
23
|
+
selectedPath: null,
|
|
24
|
+
expandedPaths: new Set<string>(),
|
|
25
|
+
|
|
26
|
+
loadTree: async () => {
|
|
27
|
+
// No-op in ccmc; the documents page manages its own tree.
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
selectPage: (path: string | null) => {
|
|
31
|
+
set({ selectedPath: path });
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
expandPath: (path: string) => {
|
|
35
|
+
const { expandedPaths } = get();
|
|
36
|
+
if (!expandedPaths.has(path)) {
|
|
37
|
+
const next = new Set(expandedPaths);
|
|
38
|
+
next.add(path);
|
|
39
|
+
set({ expandedPaths: next });
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
focusPath: (path: string) => {
|
|
44
|
+
const { expandedPaths } = get();
|
|
45
|
+
const next = new Set(expandedPaths);
|
|
46
|
+
const parts = path.split("/");
|
|
47
|
+
for (let i = 1; i < parts.length; i++) {
|
|
48
|
+
next.add(parts.slice(0, i).join("/"));
|
|
49
|
+
}
|
|
50
|
+
set({ selectedPath: path, expandedPaths: next });
|
|
51
|
+
},
|
|
52
|
+
}));
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { create } from "zustand";
|
|
3
|
+
|
|
4
|
+
type Dir = "entities" | "concepts" | "comparisons" | "root";
|
|
5
|
+
|
|
6
|
+
interface SlugBuckets {
|
|
7
|
+
entities: string[];
|
|
8
|
+
concepts: string[];
|
|
9
|
+
comparisons: string[];
|
|
10
|
+
root: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface WikiSlugsState {
|
|
14
|
+
slugs: Set<string>;
|
|
15
|
+
slugDir: Map<string, Dir>;
|
|
16
|
+
loadedAt: number | null;
|
|
17
|
+
loading: boolean;
|
|
18
|
+
load(): Promise<void>;
|
|
19
|
+
has(slug: string): boolean;
|
|
20
|
+
getDir(slug: string): Dir | null;
|
|
21
|
+
invalidate(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type { Dir as WikiSlugDir };
|
|
25
|
+
|
|
26
|
+
export const useWikiSlugsStore = create<WikiSlugsState>((set, get) => ({
|
|
27
|
+
slugs: new Set<string>(),
|
|
28
|
+
slugDir: new Map<string, Dir>(),
|
|
29
|
+
loadedAt: null,
|
|
30
|
+
loading: false,
|
|
31
|
+
|
|
32
|
+
load: async () => {
|
|
33
|
+
const { loadedAt, loading } = get();
|
|
34
|
+
if (loading) return;
|
|
35
|
+
if (loadedAt !== null && Date.now() - loadedAt < 10_000) return;
|
|
36
|
+
|
|
37
|
+
set({ loading: true });
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch("/api/wiki/slugs");
|
|
40
|
+
if (!res.ok) throw new Error("Failed to fetch wiki slugs");
|
|
41
|
+
const buckets = (await res.json()) as SlugBuckets;
|
|
42
|
+
|
|
43
|
+
const slugs = new Set<string>();
|
|
44
|
+
const slugDir = new Map<string, Dir>();
|
|
45
|
+
|
|
46
|
+
const dirs: Dir[] = ["entities", "concepts", "comparisons", "root"];
|
|
47
|
+
for (const dir of dirs) {
|
|
48
|
+
for (const slug of buckets[dir]) {
|
|
49
|
+
slugs.add(slug);
|
|
50
|
+
// First bucket wins if a slug appears in multiple (shouldn't happen)
|
|
51
|
+
if (!slugDir.has(slug)) {
|
|
52
|
+
slugDir.set(slug, dir);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
set({ slugs, slugDir, loadedAt: Date.now(), loading: false });
|
|
58
|
+
} catch {
|
|
59
|
+
set({ loading: false });
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
has(slug: string): boolean {
|
|
64
|
+
return get().slugs.has(slug);
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
getDir(slug: string): Dir | null {
|
|
68
|
+
return get().slugDir.get(slug) ?? null;
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
invalidate(): void {
|
|
72
|
+
set({ loadedAt: null });
|
|
73
|
+
},
|
|
74
|
+
}));
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Editor types used by the editor, stores, and viewer components.
|
|
2
|
+
|
|
3
|
+
export interface GoogleFrontmatter {
|
|
4
|
+
kind?: "sheets" | "slides" | "docs" | "forms" | "drive";
|
|
5
|
+
url: string;
|
|
6
|
+
embedUrl?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface FrontMatter {
|
|
10
|
+
title: string;
|
|
11
|
+
created: string;
|
|
12
|
+
modified: string;
|
|
13
|
+
tags: string[];
|
|
14
|
+
icon?: string;
|
|
15
|
+
order?: number;
|
|
16
|
+
dir?: "ltr" | "rtl";
|
|
17
|
+
google?: GoogleFrontmatter;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TreeNode {
|
|
21
|
+
name: string;
|
|
22
|
+
path: string;
|
|
23
|
+
type:
|
|
24
|
+
| "file"
|
|
25
|
+
| "directory"
|
|
26
|
+
| "cabinet"
|
|
27
|
+
| "website"
|
|
28
|
+
| "app"
|
|
29
|
+
| "pdf"
|
|
30
|
+
| "csv"
|
|
31
|
+
| "code"
|
|
32
|
+
| "image"
|
|
33
|
+
| "video"
|
|
34
|
+
| "audio"
|
|
35
|
+
| "mermaid"
|
|
36
|
+
| "docx"
|
|
37
|
+
| "xlsx"
|
|
38
|
+
| "pptx"
|
|
39
|
+
| "notebook"
|
|
40
|
+
| "unknown";
|
|
41
|
+
hasRepo?: boolean;
|
|
42
|
+
isLinked?: boolean;
|
|
43
|
+
frontmatter?: Partial<FrontMatter>;
|
|
44
|
+
children?: TreeNode[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type SaveStatus = "idle" | "saving" | "saved" | "error";
|