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/DashboardView.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, useState } from "react";
2
import type {
3
ImportJobSummary,
4
IntegrationSummary,
5
SessionUser,
6
WebhookDeliverySummary,
7
WebhookSummary
8
} from "@ledger/shared";
9
import { api } from "../lib/api";
10
import { resolveDisplayedMcpEndpoint } from "../lib/mcp";
11
import { PageEditor } from "./PageEditor";
13
type Space = {
14
id: string;
15
name: string;
16
key: string;
17
visibility: string;
18
};
20
type AdminFeedback = Array<{ id: string; page_title: string; helpful: boolean; comment: string | null }>;
22
export function DashboardView({ user, spaces }: { user: SessionUser; spaces: Space[] }) {
23
const canAdmin = user.role === "admin" || user.role === "owner";
24
const canEdit = canAdmin || user.role === "editor";
25
const [analytics, setAnalytics] = useState<{
26
topSearches: Array<{ query: string; count: number }>;
27
noResults: Array<{ query: string; count: number }>;
28
} | null>(null);
29
const [feedback, setFeedback] = useState<AdminFeedback | null>(null);
30
const [integrations, setIntegrations] = useState<IntegrationSummary[]>([]);
31
const [importJobs, setImportJobs] = useState<ImportJobSummary[]>([]);
32
const [webhooks, setWebhooks] = useState<WebhookSummary[]>([]);
33
const [deliveries, setDeliveries] = useState<Record<string, WebhookDeliverySummary[]>>({});
34
const [aiSettings, setAiSettings] = useState({
35
provider: "none",
36
model: "",
37
isEnabled: false,
38
hasApiKey: false,
39
apiKey: ""
40
});
41
const [brandingForm, setBrandingForm] = useState({
42
siteName: "Ledger",
43
logoUrl: "",
44
brandColor: "#245cff",
45
publicKnowledgeBaseEnabled: true
46
});
47
const [status, setStatus] = useState<string | null>(null);
48
const [webhookForm, setWebhookForm] = useState({
49
name: "",
50
targetUrl: "",
51
signingSecret: "",
52
isActive: true,
53
events: "page.created, page.updated"
54
});
55
const [integrationForms, setIntegrationForms] = useState<Record<string, { name: string; isEnabled: boolean; token: string; accessToken: string }>>({
56
github: { name: "GitHub", isEnabled: false, token: "", accessToken: "" },
57
google_docs: { name: "Google Docs", isEnabled: false, token: "", accessToken: "" },
58
markdown_import: { name: "Markdown import", isEnabled: true, token: "", accessToken: "" }
59
});
60
const [markdownFiles, setMarkdownFiles] = useState<Array<{ fileName: string; content: string }>>([]);
61
const [markdownPreview, setMarkdownPreview] = useState<Array<{ title: string; excerpt?: string }> | null>(null);
62
const [githubImport, setGithubImport] = useState({ repo: "", branch: "main", path: "" });
63
const [githubPreview, setGithubPreview] = useState<{ title: string; excerpt?: string } | null>(null);
64
const [googleImport, setGoogleImport] = useState({ documentId: "" });
65
const [googlePreview, setGooglePreview] = useState<{ title: string; excerpt?: string } | null>(null);
66
const [importTarget, setImportTarget] = useState({
67
spaceId: spaces[0]?.id ?? "",
68
visibility: "internal",
69
state: "draft"
70
});
71
const displayedMcpEndpoint = resolveDisplayedMcpEndpoint();
73
async function loadAdminData() {
74
if (!canAdmin) {
75
return;
76
}
78
const [analyticsResponse, feedbackResponse, settingsResponse, integrationsResponse, jobsResponse, aiResponse, webhookResponse] =
79
await Promise.all([
80
api.get<{ topSearches: Array<{ query: string; count: number }>; noResults: Array<{ query: string; count: number }> }>("/api/admin/search-analytics"),
81
api.get<{ feedback: AdminFeedback }>("/api/admin/feedback"),
82
api.get<{ branding: { site_name: string; logo_url: string | null; brand_color: string; footer_text: string | null; public_knowledge_base_enabled: boolean } }>("/api/settings/admin"),
83
api.get<{ integrations: IntegrationSummary[] }>("/api/integrations"),
84
api.get<{ jobs: ImportJobSummary[] }>("/api/integrations/import-jobs"),
85
api.get<{ settings: { provider: string; model: string; isEnabled: boolean; hasApiKey: boolean } }>("/api/ai/settings"),
86
api.get<{ webhooks: WebhookSummary[] }>("/api/webhooks")
87
]);
89
setAnalytics(analyticsResponse);
90
setFeedback(feedbackResponse.feedback);
91
setIntegrations(integrationsResponse.integrations);
92
setImportJobs(jobsResponse.jobs);
93
setAiSettings((current) => ({ ...current, ...aiResponse.settings, apiKey: "" }));
94
setWebhooks(webhookResponse.webhooks);
95
setBrandingForm({
96
siteName: settingsResponse.branding.site_name,
97
logoUrl: settingsResponse.branding.logo_url ?? "",
98
brandColor: settingsResponse.branding.brand_color,
99
publicKnowledgeBaseEnabled: settingsResponse.branding.public_knowledge_base_enabled
100
});
101
}
103
useEffect(() => {
104
void loadAdminData();
105
}, [canAdmin]);
107
async function saveBranding() {
108
await api.put("/api/settings/branding", {
109
siteName: brandingForm.siteName,
110
logoUrl: brandingForm.logoUrl || null,
111
brandColor: brandingForm.brandColor,
112
publicKnowledgeBaseEnabled: brandingForm.publicKnowledgeBaseEnabled
113
});
114
setStatus("Workspace settings saved.");
115
}
117
async function saveIntegration(provider: "github" | "google_docs" | "markdown_import") {
118
const form = integrationForms[provider];
119
const config =
120
provider === "github"
121
? { token: form.token }
122
: provider === "google_docs"
123
? { accessToken: form.accessToken }
124
: {};
126
await api.put(`/api/integrations/${provider}`, {
127
name: form.name,
128
isEnabled: form.isEnabled,
129
config
130
});
131
setStatus(`${provider} settings saved.`);
132
await loadAdminData();
133
}
135
async function loadDeliveries(webhookId: string) {
136
const response = await api.get<{ deliveries: WebhookDeliverySummary[] }>(`/api/webhooks/${webhookId}/deliveries`);
137
setDeliveries((current) => ({ ...current, [webhookId]: response.deliveries }));
138
}
140
async function createWebhook() {
141
await api.post("/api/webhooks", {
142
...webhookForm,
143
events: webhookForm.events.split(",").map((value) => value.trim()).filter(Boolean)
144
});
145
setStatus("Webhook created.");
146
await loadAdminData();
147
}
149
async function deleteWebhook(webhookId: string) {
150
if (!window.confirm("Delete this webhook and its recent deliveries?")) return;
151
await api.delete(`/api/webhooks/${webhookId}`);
152
setStatus("Webhook deleted.");
153
await loadAdminData();
154
}
156
async function saveAi() {
157
await api.put("/api/ai/settings", {
158
provider: aiSettings.provider,
159
model: aiSettings.model,
160
apiKey: aiSettings.apiKey || null,
161
isEnabled: aiSettings.isEnabled
162
});
163
setStatus("AI settings saved.");
164
await loadAdminData();
165
}
167
async function handleMarkdownFiles(fileList: FileList | null) {
168
if (!fileList) return;
170
const files = await Promise.all(
171
Array.from(fileList).map(
172
(file) =>
173
new Promise<{ fileName: string; content: string }>((resolve, reject) => {
174
const reader = new FileReader();
175
reader.onload = () => resolve({ fileName: file.name, content: String(reader.result ?? "") });
176
reader.onerror = () => reject(reader.error);
177
reader.readAsText(file);
178
})
179
)
180
);
182
setMarkdownFiles(files);
183
const response = await api.post<{ documents: Array<{ title: string; excerpt?: string }> }>("/api/integrations/markdown/preview", {
184
files
185
});
186
setMarkdownPreview(response.documents);
187
}
189
async function runMarkdownImport() {
190
await api.post("/api/integrations/markdown/import", {
191
files: markdownFiles,
192
target: importTarget
193
});
194
setStatus("Markdown import completed.");
195
await loadAdminData();
196
}
198
async function previewGithubImport() {
199
const response = await api.post<{ document: { title: string; excerpt?: string } }>("/api/integrations/github/preview", githubImport);
200
setGithubPreview(response.document);
201
}
203
async function runGithubImport() {
204
await api.post("/api/integrations/github/import", {
205
...githubImport,
206
target: importTarget
207
});
208
setStatus("GitHub import completed.");
209
await loadAdminData();
210
}
212
async function previewGoogleImport() {
213
const response = await api.post<{ document: { title: string; excerpt?: string } }>("/api/integrations/google-docs/preview", googleImport);
214
setGooglePreview(response.document);
215
}
217
async function runGoogleImport() {
218
await api.post("/api/integrations/google-docs/import", {
219
...googleImport,
220
target: importTarget
221
});
222
setStatus("Google Docs import completed.");
223
await loadAdminData();
224
}
226
return (
227
<div className="manage-layout">
228
<section className="panel manage-hero">
229
<div>
230
<p className="eyebrow">Manage workspace</p>
231
<h1>{user.displayName}</h1>
232
<p className="lede">Publish docs, import trusted sources, configure AI, and control integrations with an audit-friendly admin surface.</p>
233
</div>
234
<div className="manage-hero__meta">
235
<span className="pill">{user.role}</span>
236
<span className="pill">{spaces.length} collections</span>
237
</div>
238
</section>
240
{status ? <p className="muted">{status}</p> : null}
242
<div className="manage-grid">
243
{canEdit ? <PageEditor spaces={spaces} /> : null}
245
<section className="panel" id="imports">
246
<div className="panel__header">
247
<div>
248
<p className="eyebrow">Imports</p>
249
<h3>Bring documentation into Ledger</h3>
250
</div>
251
</div>
252
<div className="field-grid">
253
<label className="field">
254
Target collection
255
<select value={importTarget.spaceId} onChange={(event) => setImportTarget((current) => ({ ...current, spaceId: event.target.value }))}>
256
{spaces.map((space) => (
257
<option key={space.id} value={space.id}>
258
{space.name}
259
</option>
260
))}
261
</select>
262
</label>
263
<label className="field">
264
State
265
<select value={importTarget.state} onChange={(event) => setImportTarget((current) => ({ ...current, state: event.target.value }))}>
266
<option value="draft">Draft</option>
267
<option value="published">Published</option>
268
</select>
269
</label>
270
</div>
271
<div className="field-grid">
272
<label className="field">
273
Visibility
274
<select value={importTarget.visibility} onChange={(event) => setImportTarget((current) => ({ ...current, visibility: event.target.value }))}>
275
<option value="internal">Internal</option>
276
<option value="public">Public</option>
277
<option value="restricted">Restricted</option>
278
</select>
279
</label>
280
<div className="field field-note">
281
Browser note
282
<p className="muted">Ledger supports multiple Markdown files in the browser. Folder upload support depends on the browser, so multiple-file import is the reliable default.</p>
283
</div>
284
</div>
286
<div className="import-grid">
287
<section className="subpanel">
288
<h4>Markdown</h4>
289
<input type="file" accept=".md,.markdown,text/markdown" multiple onChange={(event) => void handleMarkdownFiles(event.target.files)} />
290
{markdownPreview ? (
291
<div className="preview-list">
292
{markdownPreview.map((document) => (
293
<div key={document.title} className="preview-item">
294
<strong>{document.title}</strong>
295
<p>{document.excerpt ?? "No excerpt available."}</p>
296
</div>
297
))}
298
<button onClick={runMarkdownImport}>Import Markdown</button>
299
</div>
300
) : null}
301
</section>
303
<section className="subpanel">
304
<h4>GitHub</h4>
305
<label className="field">
306
Repository
307
<input value={githubImport.repo} onChange={(event) => setGithubImport((current) => ({ ...current, repo: event.target.value }))} placeholder="org/repo" />
308
</label>
309
<div className="field-grid">
310
<label className="field">
311
Branch
312
<input value={githubImport.branch} onChange={(event) => setGithubImport((current) => ({ ...current, branch: event.target.value }))} />
313
</label>
314
<label className="field">
315
Path
316
<input value={githubImport.path} onChange={(event) => setGithubImport((current) => ({ ...current, path: event.target.value }))} placeholder="docs/getting-started.md" />
317
</label>
318
</div>
319
<div className="panel__footer">
320
<button className="button-secondary" onClick={previewGithubImport}>Preview</button>
321
<button onClick={runGithubImport}>Import</button>
322
</div>
323
{githubPreview ? <div className="preview-item"><strong>{githubPreview.title}</strong><p>{githubPreview.excerpt ?? "No excerpt available."}</p></div> : null}
324
</section>
326
<section className="subpanel">
327
<h4>Google Docs</h4>
328
<label className="field">
329
Document ID
330
<input value={googleImport.documentId} onChange={(event) => setGoogleImport({ documentId: event.target.value })} placeholder="Google Doc ID" />
331
</label>
332
<div className="panel__footer">
333
<button className="button-secondary" onClick={previewGoogleImport}>Preview</button>
334
<button onClick={runGoogleImport}>Import</button>
335
</div>
336
{googlePreview ? <div className="preview-item"><strong>{googlePreview.title}</strong><p>{googlePreview.excerpt ?? "No excerpt available."}</p></div> : null}
337
</section>
338
</div>
339
</section>
341
{canAdmin ? (
342
<>
343
<section className="panel" id="settings">
344
<div className="panel__header">
345
<div>
346
<p className="eyebrow">Branding</p>
347
<h3>Workspace settings</h3>
348
</div>
349
</div>
350
<label className="field">
351
Site name
352
<input value={brandingForm.siteName} onChange={(event) => setBrandingForm((current) => ({ ...current, siteName: event.target.value }))} />
353
</label>
354
<div className="field-grid">
355
<label className="field">
356
Brand color
357
<input value={brandingForm.brandColor} onChange={(event) => setBrandingForm((current) => ({ ...current, brandColor: event.target.value }))} />
358
</label>
359
<label className="field">
360
Logo URL
361
<input value={brandingForm.logoUrl} onChange={(event) => setBrandingForm((current) => ({ ...current, logoUrl: event.target.value }))} />
362
</label>
363
</div>
364
<div className="list-item">
365
<strong>Footer</strong>
366
<span>Powered by Ledger made by ANord.cc</span>
367
</div>
368
<label className="checkbox-row checkbox-card">
369
<input type="checkbox" checked={brandingForm.publicKnowledgeBaseEnabled} onChange={(event) => setBrandingForm((current) => ({ ...current, publicKnowledgeBaseEnabled: event.target.checked }))} />
370
<span>Public knowledge base enabled</span>
371
</label>
372
<div className="panel__footer">
373
<button onClick={saveBranding}>Save settings</button>
374
</div>
375
</section>
377
<section className="panel" id="integrations">
378
<div className="panel__header">
379
<div>
380
<p className="eyebrow">Integrations</p>
381
<h3>Provider configuration</h3>
382
</div>
383
</div>
384
<div className="integration-list">
385
{(["github", "google_docs", "markdown_import"] as const).map((provider) => {
386
const currentIntegration = integrations.find((integration) => integration.provider === provider);
387
const form = integrationForms[provider];
388
return (
389
<div key={provider} className="integration-card">
390
<div className="feedback-item__header">
391
<strong>{provider.replace("_", " ")}</strong>
392
<span className={`badge badge-${currentIntegration?.status === "configured" ? "public" : "restricted"}`}>
393
{currentIntegration?.statusMessage ?? "Not configured"}
394
</span>
395
</div>
396
<label className="field">
397
Name
398
<input value={form.name} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], name: event.target.value } }))} />
399
</label>
400
{provider === "github" ? (
401
<label className="field">
402
GitHub token
403
<input type="password" value={form.token} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], token: event.target.value } }))} />
404
</label>
405
) : null}
406
{provider === "google_docs" ? (
407
<label className="field">
408
Google access token
409
<input type="password" value={form.accessToken} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], accessToken: event.target.value } }))} />
410
</label>
411
) : null}
412
<label className="checkbox-row checkbox-card">
413
<input type="checkbox" checked={form.isEnabled} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], isEnabled: event.target.checked } }))} />
414
<span>Enable {provider.replace("_", " ")}</span>
415
</label>
416
<div className="panel__footer">
417
<button onClick={() => saveIntegration(provider)}>Save integration</button>
418
</div>
419
</div>
420
);
421
})}
422
</div>
423
</section>
425
<section className="panel" id="webhooks">
426
<div className="panel__header">
427
<div>
428
<p className="eyebrow">Webhooks</p>
429
<h3>Signed event delivery</h3>
430
</div>
431
</div>
432
<div className="field-grid">
433
<label className="field">
434
Name
435
<input value={webhookForm.name} onChange={(event) => setWebhookForm((current) => ({ ...current, name: event.target.value }))} />
436
</label>
437
<label className="field">
438
Target URL
439
<input value={webhookForm.targetUrl} onChange={(event) => setWebhookForm((current) => ({ ...current, targetUrl: event.target.value }))} />
440
</label>
441
</div>
442
<label className="field">
443
Signing secret
444
<input value={webhookForm.signingSecret} onChange={(event) => setWebhookForm((current) => ({ ...current, signingSecret: event.target.value }))} />
445
</label>
446
<label className="field">
447
Events
448
<input value={webhookForm.events} onChange={(event) => setWebhookForm((current) => ({ ...current, events: event.target.value }))} />
449
</label>
450
<label className="checkbox-row checkbox-card">
451
<input type="checkbox" checked={webhookForm.isActive} onChange={(event) => setWebhookForm((current) => ({ ...current, isActive: event.target.checked }))} />
452
<span>Active</span>
453
</label>
454
<p className="muted">Ledger signs `timestamp.body` with HMAC SHA-256 and sends `X-Ledger-Event`, `X-Ledger-Timestamp`, and `X-Ledger-Signature` headers.</p>
455
<div className="panel__footer">
456
<button onClick={createWebhook}>Create webhook</button>
457
</div>
458
<div className="feedback-list">
459
{webhooks.map((webhook) => (
460
<div key={webhook.id} className="feedback-item">
461
<div className="feedback-item__header">
462
<strong>{webhook.name}</strong>
463
<span className={`badge badge-${webhook.isActive ? "public" : "restricted"}`}>{webhook.isActive ? "Active" : "Disabled"}</span>
464
</div>
465
<p>{webhook.targetUrl}</p>
466
<p className="muted">{webhook.events.join(", ")}</p>
467
<div className="panel__footer">
468
<button className="button-secondary" onClick={() => loadDeliveries(webhook.id)}>Load deliveries</button>
469
<button className="button-secondary" onClick={() => deleteWebhook(webhook.id)}>Delete</button>
470
</div>
471
{deliveries[webhook.id]?.length ? (
472
<div className="delivery-list">
473
{deliveries[webhook.id].map((delivery) => (
474
<div key={delivery.id} className="delivery-item">
475
<strong>{delivery.eventName}</strong>
476
<span>{delivery.responseStatus ?? "pending"}</span>
477
<span>{delivery.success ? "Success" : delivery.errorMessage ?? "Failed"}</span>
478
</div>
479
))}
480
</div>
481
) : null}
482
</div>
483
))}
484
</div>
485
</section>
487
<section className="panel" id="ai">
488
<div className="panel__header">
489
<div>
490
<p className="eyebrow">AI settings</p>
491
<h3>Permissions-aware answers</h3>
492
</div>
493
</div>
494
<div className="field-grid">
495
<label className="field">
496
Provider
497
<select value={aiSettings.provider} onChange={(event) => setAiSettings((current) => ({ ...current, provider: event.target.value }))}>
498
<option value="none">Disabled</option>
499
<option value="openai_compatible">OpenAI-compatible</option>
500
<option value="anthropic_compatible">Anthropic-compatible</option>
501
</select>
502
</label>
503
<label className="field">
504
Model
505
<input value={aiSettings.model} onChange={(event) => setAiSettings((current) => ({ ...current, model: event.target.value }))} />
506
</label>
507
</div>
508
<label className="field">
509
API key
510
<input type="password" value={aiSettings.apiKey} onChange={(event) => setAiSettings((current) => ({ ...current, apiKey: event.target.value }))} placeholder={aiSettings.hasApiKey ? "Configured. Enter a new key to rotate it." : "Paste provider API key"} />
511
</label>
512
<label className="checkbox-row checkbox-card">
513
<input type="checkbox" checked={aiSettings.isEnabled} onChange={(event) => setAiSettings((current) => ({ ...current, isEnabled: event.target.checked }))} />
514
<span>Enable AI answers</span>
515
</label>
516
<p className="muted">AI uses the same page visibility rules as search and document reads, so restricted content is never exposed to the wrong user.</p>
517
<div className="panel__footer">
518
<button onClick={saveAi}>Save AI settings</button>
519
</div>
520
</section>
522
<section className="panel" id="mcp">
523
<div className="panel__header">
524
<div>
525
<p className="eyebrow">MCP</p>
526
<h3>Ledger MCP server</h3>
527
</div>
528
</div>
529
<p className="muted">Endpoint: {displayedMcpEndpoint}</p>
530
<p className="muted">Tools: `search_knowledge_base`, `read_page`, `list_spaces`, `get_page_metadata`, `create_draft_page`</p>
531
<p className="muted">Auth: same session context as the web app.</p>
532
</section>
534
<section className="panel">
535
<div className="panel__header">
536
<div>
537
<p className="eyebrow">Import history</p>
538
<h3>Recent jobs</h3>
539
</div>
540
</div>
541
<div className="feedback-list">
542
{importJobs.map((job) => (
543
<div key={job.id} className="feedback-item">
544
<div className="feedback-item__header">
545
<strong>{job.sourceLabel}</strong>
546
<span className={`badge badge-${job.status === "completed" ? "public" : "restricted"}`}>{job.status}</span>
547
</div>
548
<p>{job.provider} • {job.importedCount} pages</p>
549
{job.errorMessage ? <p className="muted">{job.errorMessage}</p> : null}
550
</div>
551
))}
552
</div>
553
</section>
555
{analytics ? (
556
<section className="panel">
557
<div className="panel__header">
558
<div>
559
<p className="eyebrow">Analytics</p>
560
<h3>Search performance</h3>
561
</div>
562
</div>
563
<div className="analytics-grid">
564
<div>
565
<strong>Top searches</strong>
566
{analytics.topSearches.map((item) => (
567
<p key={item.query}>{item.query} <span className="muted">({item.count})</span></p>
568
))}
569
</div>
570
<div>
571
<strong>No results</strong>
572
{analytics.noResults.map((item) => (
573
<p key={item.query}>{item.query} <span className="muted">({item.count})</span></p>
574
))}
575
</div>
576
</div>
577
</section>
578
) : null}
580
{feedback ? (
581
<section className="panel">
582
<div className="panel__header">
583
<div>
584
<p className="eyebrow">Feedback queue</p>
585
<h3>Reader responses</h3>
586
</div>
587
</div>
588
<div className="feedback-list">
589
{feedback.map((item) => (
590
<div key={item.id} className="feedback-item">
591
<div className="feedback-item__header">
592
<strong>{item.page_title}</strong>
593
<span className={`badge ${item.helpful ? "badge-public" : "badge-restricted"}`}>
594
{item.helpful ? "Helpful" : "Not helpful"}
595
</span>
596
</div>
597
<p>{item.comment ?? "No comment provided."}</p>
598
</div>
599
))}
600
</div>
601
</section>
602
) : null}
603
</>
604
) : null}
605
</div>
606
</div>
607
);
608
}