public
anord
read
Ledger
Why work hard when you can work easier?
Languages
Repository composition by tracked source files.
TypeScript
86%
CSS
10%
SQL
3%
Shell
1%
HTML
0%
Trace
apps/web/src/components/ImportsPage.tsx
Trace helps you understand code history line by line. See who changed each line, when it changed, and which commit introduced it.
Author
Date
Commit
Line
Code
1
import { useEffect, useMemo, useState } from "react";
2
import type { ImportJobSummary, IntegrationSummary, SessionUser } from "@ledger/shared";
3
import { api } from "../lib/api";
4
import { EmptyState } from "./EmptyState";
5
import { PageHeader } from "./PageHeader";
7
type Space = {
8
id: string;
9
name: string;
10
key: string;
11
visibility: string;
12
};
14
type ImportSource = "markdown" | "github" | "google_docs";
16
export function ImportsPage({
17
user,
18
spaces
19
}: {
20
user: SessionUser | null;
21
spaces: Space[];
22
}) {
23
const [source, setSource] = useState<ImportSource>("markdown");
24
const [step, setStep] = useState<"configure" | "preview" | "imported">("configure");
25
const [integrations, setIntegrations] = useState<IntegrationSummary[]>([]);
26
const [jobs, setJobs] = useState<ImportJobSummary[]>([]);
27
const [status, setStatus] = useState<string | null>(null);
28
const [markdownFiles, setMarkdownFiles] = useState<Array<{ fileName: string; content: string }>>([]);
29
const [markdownPreview, setMarkdownPreview] = useState<Array<{ title: string; excerpt?: string }> | null>(null);
30
const [github, setGithub] = useState({ repo: "", branch: "main", path: "" });
31
const [githubPreview, setGithubPreview] = useState<{ title: string; excerpt?: string } | null>(null);
32
const [googleDocId, setGoogleDocId] = useState("");
33
const [googlePreview, setGooglePreview] = useState<{ title: string; excerpt?: string } | null>(null);
34
const [target, setTarget] = useState({
35
spaceId: spaces[0]?.id ?? "",
36
visibility: "internal",
37
state: "draft"
38
});
40
const canImport = Boolean(user && user.role !== "viewer" && user.role !== "public");
42
const githubIntegration = integrations.find((item) => item.provider === "github");
43
const googleIntegration = integrations.find((item) => item.provider === "google_docs");
45
useEffect(() => {
46
if (!canImport) return;
48
api.get<{ integrations: IntegrationSummary[] }>("/api/integrations").then((response) => setIntegrations(response.integrations));
49
api.get<{ jobs: ImportJobSummary[] }>("/api/integrations/import-jobs").then((response) => setJobs(response.jobs));
50
}, [canImport]);
52
const sourceCards = useMemo(
53
() => [
54
{
55
key: "markdown" as const,
56
title: "Markdown Import",
57
description: "Import one or more Markdown files directly from your browser.",
58
status: "Ready"
59
},
60
{
61
key: "github" as const,
62
title: "GitHub",
63
description: "Import Markdown content from a repository path and branch.",
64
status: githubIntegration?.statusMessage ?? "Not configured"
65
},
66
{
67
key: "google_docs" as const,
68
title: "Google Docs",
69
description: "Import a Google Doc into Markdown while preserving basic formatting.",
70
status: googleIntegration?.statusMessage ?? "Not configured"
71
}
72
],
73
[githubIntegration?.statusMessage, googleIntegration?.statusMessage]
74
);
76
function sourceBadgeTone(key: ImportSource) {
77
if (key === "markdown") return "public";
78
if (key === "github") return githubIntegration?.status === "configured" ? "public" : "restricted";
79
return googleIntegration?.status === "configured" ? "public" : "restricted";
80
}
82
async function handleMarkdownFiles(fileList: FileList | null) {
83
if (!fileList) return;
85
try {
86
const files = await Promise.all(
87
Array.from(fileList).map(
88
(file) =>
89
new Promise<{ fileName: string; content: string }>((resolve, reject) => {
90
const reader = new FileReader();
91
reader.onload = () => resolve({ fileName: file.name, content: String(reader.result ?? "") });
92
reader.onerror = () => reject(reader.error);
93
reader.readAsText(file);
94
})
95
)
96
);
98
setMarkdownFiles(files);
99
const response = await api.post<{ documents: Array<{ title: string; excerpt?: string }> }>("/api/integrations/markdown/preview", {
100
files
101
});
102
setMarkdownPreview(response.documents);
103
setStep("preview");
104
setStatus(null);
105
} catch (error) {
106
setStatus(error instanceof Error ? error.message : "Could not preview Markdown files.");
107
}
108
}
110
async function previewGithub() {
111
try {
112
const response = await api.post<{ document: { title: string; excerpt?: string } }>("/api/integrations/github/preview", github);
113
setGithubPreview(response.document);
114
setStep("preview");
115
setStatus(null);
116
} catch (error) {
117
setStatus(error instanceof Error ? error.message : "Could not preview GitHub content.");
118
}
119
}
121
async function previewGoogle() {
122
try {
123
const response = await api.post<{ document: { title: string; excerpt?: string } }>("/api/integrations/google-docs/preview", {
124
documentId: googleDocId
125
});
126
setGooglePreview(response.document);
127
setStep("preview");
128
setStatus(null);
129
} catch (error) {
130
setStatus(error instanceof Error ? error.message : "Could not preview Google Docs content.");
131
}
132
}
134
async function runImport() {
135
try {
136
if (source === "markdown") {
137
await api.post("/api/integrations/markdown/import", { files: markdownFiles, target });
138
}
140
if (source === "github") {
141
await api.post("/api/integrations/github/import", { ...github, target });
142
}
144
if (source === "google_docs") {
145
await api.post("/api/integrations/google-docs/import", { documentId: googleDocId, target });
146
}
148
setStatus("Import completed successfully.");
149
setStep("imported");
150
if (canImport) {
151
const response = await api.get<{ jobs: ImportJobSummary[] }>("/api/integrations/import-jobs");
152
setJobs(response.jobs);
153
}
154
} catch (error) {
155
setStatus(error instanceof Error ? error.message : "Import failed.");
156
}
157
}
159
const sourceReady =
160
source === "markdown" ||
161
(source === "github" && githubIntegration?.status === "configured") ||
162
(source === "google_docs" && googleIntegration?.status === "configured");
164
return (
165
<div className="stack-page">
166
<PageHeader
167
eyebrow="Imports"
168
title="Import documentation"
169
description="Select a source, preview imported content, choose the target space, and keep a clean history of every import run."
170
/>
172
{!canImport ? (
173
<section className="panel">
174
<EmptyState
175
title="Imports require editor access"
176
description="Sign in with an editor, admin, or owner account to import content into Ledger."
177
/>
178
</section>
179
) : (
180
<>
181
<section className="panel">
182
<div className="panel__header">
183
<div>
184
<p className="eyebrow">Step 1</p>
185
<h3>Select a source</h3>
186
</div>
187
</div>
188
<div className="integration-list">
189
{sourceCards.map((card) => (
190
<button
191
key={card.key}
192
type="button"
193
className={`integration-card integration-card--selectable${source === card.key ? " is-selected" : ""}`}
194
onClick={() => {
195
setSource(card.key);
196
setStep("configure");
197
}}
198
>
199
<div className="feedback-item__header">
200
<strong>{card.title}</strong>
201
<span className={`badge badge-${sourceBadgeTone(card.key)}`}>
202
{card.status}
203
</span>
204
</div>
205
<p>{card.description}</p>
206
</button>
207
))}
208
</div>
209
</section>
211
<section className="panel">
212
<div className="panel__header">
213
<div>
214
<p className="eyebrow">Step 2</p>
215
<h3>Configure target</h3>
216
</div>
217
</div>
218
<div className="field-grid">
219
<label className="field">
220
Target space
221
<select value={target.spaceId} onChange={(event) => setTarget((current) => ({ ...current, spaceId: event.target.value }))}>
222
{spaces.map((space) => (
223
<option key={space.id} value={space.id}>
224
{space.name}
225
</option>
226
))}
227
</select>
228
</label>
229
<label className="field">
230
Publish state
231
<select value={target.state} onChange={(event) => setTarget((current) => ({ ...current, state: event.target.value }))}>
232
<option value="draft">Draft</option>
233
<option value="published">Published</option>
234
</select>
235
</label>
236
</div>
237
<label className="field">
238
Visibility
239
<select value={target.visibility} onChange={(event) => setTarget((current) => ({ ...current, visibility: event.target.value }))}>
240
<option value="internal">Internal</option>
241
<option value="public">Public</option>
242
<option value="restricted">Restricted</option>
243
</select>
244
</label>
245
</section>
247
<section className="panel">
248
<div className="panel__header">
249
<div>
250
<p className="eyebrow">Step 3</p>
251
<h3>Preview source content</h3>
252
</div>
253
</div>
255
{!sourceReady ? (
256
<EmptyState
257
title="Source not configured"
258
description={`Configure ${source === "github" ? "GitHub" : source === "google_docs" ? "Google Docs" : "Markdown import"} before previewing imports.`}
259
/>
260
) : null}
262
{source === "markdown" ? (
263
<div className="stack">
264
<input type="file" accept=".md,.markdown,text/markdown" multiple onChange={(event) => void handleMarkdownFiles(event.target.files)} />
265
<p className="muted">Multiple-file upload is supported directly. Folder upload varies by browser, so Ledger uses multiple files as the reliable default.</p>
266
{markdownPreview ? (
267
<div className="preview-list">
268
{markdownPreview.map((document) => (
269
<div key={document.title} className="preview-item">
270
<strong>{document.title}</strong>
271
<p>{document.excerpt ?? "No excerpt available."}</p>
272
</div>
273
))}
274
</div>
275
) : null}
276
</div>
277
) : null}
279
{source === "github" ? (
280
<div className="stack">
281
<div className="field-grid">
282
<label className="field">
283
Repository
284
<input value={github.repo} onChange={(event) => setGithub((current) => ({ ...current, repo: event.target.value }))} placeholder="org/repo" />
285
</label>
286
<label className="field">
287
Branch
288
<input value={github.branch} onChange={(event) => setGithub((current) => ({ ...current, branch: event.target.value }))} />
289
</label>
290
</div>
291
<label className="field">
292
Path
293
<input value={github.path} onChange={(event) => setGithub((current) => ({ ...current, path: event.target.value }))} placeholder="docs/getting-started.md" />
294
</label>
295
<div className="panel__footer">
296
<button type="button" className="button-secondary" disabled={!github.repo || !github.path || !sourceReady} onClick={previewGithub}>
297
Preview GitHub file
298
</button>
299
</div>
300
{githubPreview ? <div className="preview-item"><strong>{githubPreview.title}</strong><p>{githubPreview.excerpt ?? "No excerpt available."}</p></div> : null}
301
</div>
302
) : null}
304
{source === "google_docs" ? (
305
<div className="stack">
306
<label className="field">
307
Google Doc ID
308
<input value={googleDocId} onChange={(event) => setGoogleDocId(event.target.value)} placeholder="Document ID" />
309
</label>
310
<div className="panel__footer">
311
<button type="button" className="button-secondary" disabled={!googleDocId || !sourceReady} onClick={previewGoogle}>
312
Preview Google Doc
313
</button>
314
</div>
315
{googlePreview ? <div className="preview-item"><strong>{googlePreview.title}</strong><p>{googlePreview.excerpt ?? "No excerpt available."}</p></div> : null}
316
</div>
317
) : null}
318
</section>
320
<section className="panel">
321
<div className="panel__header">
322
<div>
323
<p className="eyebrow">Step 4</p>
324
<h3>Import</h3>
325
</div>
326
</div>
327
<div className="panel__footer">
328
<button
329
type="button"
330
disabled={
331
!sourceReady ||
332
(source === "markdown" && !markdownPreview?.length) ||
333
(source === "github" && !githubPreview) ||
334
(source === "google_docs" && !googlePreview)
335
}
336
onClick={runImport}
337
>
338
Import into Ledger
339
</button>
340
{status ? <p className="muted">{status}</p> : null}
341
{step === "imported" ? <p className="muted">Import finished. Source metadata will appear on imported pages.</p> : null}
342
</div>
343
</section>
345
<section className="panel">
346
<div className="panel__header">
347
<div>
348
<p className="eyebrow">History</p>
349
<h3>Recent imports</h3>
350
</div>
351
</div>
352
{jobs.length === 0 ? (
353
<EmptyState
354
title="No imports recorded yet"
355
description="Import history will appear here after you complete your first import."
356
/>
357
) : (
358
<div className="feedback-list">
359
{jobs.map((job) => (
360
<div key={job.id} className="feedback-item">
361
<div className="feedback-item__header">
362
<strong>{job.sourceLabel}</strong>
363
<span className={`badge badge-${job.status === "completed" ? "public" : "restricted"}`}>{job.status}</span>
364
</div>
365
<p>{job.provider} • {job.importedCount} pages</p>
366
{job.errorMessage ? <p className="muted">{job.errorMessage}</p> : null}
367
</div>
368
))}
369
</div>
370
)}
371
</section>
372
</>
373
)}
374
</div>
375
);
376
}