@lon-ask/dockit 0.1.1 → 0.1.4

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.
Files changed (140) hide show
  1. package/LICENSE +21 -674
  2. package/README.md +618 -328
  3. package/SKILL.md +61 -110
  4. package/apps/client/dist/assets/{index-CqOXxsEZ.js → index-DzadxeQH.js} +2 -2
  5. package/apps/client/dist/index.html +1 -1
  6. package/apps/client/index.html +12 -0
  7. package/apps/client/package.json +26 -0
  8. package/apps/client/src/App.tsx +18 -0
  9. package/apps/client/src/api/client.ts +54 -0
  10. package/apps/client/src/components/BuildPanel.tsx +77 -0
  11. package/apps/client/src/components/DocViewer.tsx +76 -0
  12. package/apps/client/src/components/EntryDetail.tsx +322 -0
  13. package/apps/client/src/components/EntryForm.tsx +117 -0
  14. package/apps/client/src/components/EntryList.tsx +165 -0
  15. package/apps/client/src/components/GlobalSearchBar.tsx +166 -0
  16. package/apps/client/src/components/Layout.tsx +57 -0
  17. package/apps/client/src/components/SearchBar.tsx +103 -0
  18. package/apps/client/src/components/SourceForm.tsx +497 -0
  19. package/apps/client/src/hooks/useTheme.ts +19 -0
  20. package/apps/client/src/index.css +77 -0
  21. package/apps/client/src/main.tsx +13 -0
  22. package/apps/client/src/types.ts +105 -0
  23. package/apps/client/vite.config.ts +13 -0
  24. package/apps/server/dist/core/domain/entry.js +20 -0
  25. package/apps/server/dist/core/domain/entry.js.map +1 -0
  26. package/apps/server/dist/core/domain/errors.js +33 -0
  27. package/apps/server/dist/core/domain/errors.js.map +1 -0
  28. package/apps/server/dist/core/domain/knowledge-graph.js +2 -0
  29. package/apps/server/dist/core/domain/knowledge-graph.js.map +1 -0
  30. package/apps/server/dist/core/domain/types.js +2 -0
  31. package/apps/server/dist/core/domain/types.js.map +1 -0
  32. package/apps/server/dist/core/ports/IBuildRepository.js +2 -0
  33. package/apps/server/dist/core/ports/IBuildRepository.js.map +1 -0
  34. package/apps/server/dist/core/ports/IDocumentNormalizer.js +2 -0
  35. package/apps/server/dist/core/ports/IDocumentNormalizer.js.map +1 -0
  36. package/apps/server/dist/core/ports/IDocumentStore.js +2 -0
  37. package/apps/server/dist/core/ports/IDocumentStore.js.map +1 -0
  38. package/apps/server/dist/core/ports/IEntryReadModel.js +2 -0
  39. package/apps/server/dist/core/ports/IEntryReadModel.js.map +1 -0
  40. package/apps/server/dist/core/ports/IEntryRepository.js +2 -0
  41. package/apps/server/dist/core/ports/IEntryRepository.js.map +1 -0
  42. package/apps/server/dist/core/ports/IKnowledgeGraph.js +2 -0
  43. package/apps/server/dist/core/ports/IKnowledgeGraph.js.map +1 -0
  44. package/apps/server/dist/core/ports/IPathResolver.js +2 -0
  45. package/apps/server/dist/core/ports/IPathResolver.js.map +1 -0
  46. package/apps/server/dist/core/ports/ISearchEngine.js +2 -0
  47. package/apps/server/dist/core/ports/ISearchEngine.js.map +1 -0
  48. package/apps/server/dist/core/ports/ISourceProcessor.js +2 -0
  49. package/apps/server/dist/core/ports/ISourceProcessor.js.map +1 -0
  50. package/apps/server/dist/core/ports/ISourceRepository.js +2 -0
  51. package/apps/server/dist/core/ports/ISourceRepository.js.map +1 -0
  52. package/apps/server/dist/core/usecases/BuildUseCase.js +76 -0
  53. package/apps/server/dist/core/usecases/BuildUseCase.js.map +1 -0
  54. package/apps/server/dist/core/usecases/ConfigUseCase.js +62 -0
  55. package/apps/server/dist/core/usecases/ConfigUseCase.js.map +1 -0
  56. package/apps/server/dist/core/usecases/SearchUseCase.js +17 -0
  57. package/apps/server/dist/core/usecases/SearchUseCase.js.map +1 -0
  58. package/apps/server/dist/index.js +86 -0
  59. package/apps/server/dist/index.js.map +1 -0
  60. package/apps/server/dist/infrastructure/filesystem/FileSystemDocumentStore.js +25 -0
  61. package/apps/server/dist/infrastructure/filesystem/FileSystemDocumentStore.js.map +1 -0
  62. package/apps/server/dist/infrastructure/graph/GraphSearchDecorator.js +42 -0
  63. package/apps/server/dist/infrastructure/graph/GraphSearchDecorator.js.map +1 -0
  64. package/apps/server/dist/infrastructure/graph/GraphifyKnowledgeGraph.js +145 -0
  65. package/apps/server/dist/infrastructure/graph/GraphifyKnowledgeGraph.js.map +1 -0
  66. package/apps/server/dist/infrastructure/graph/index.js +3 -0
  67. package/apps/server/dist/infrastructure/graph/index.js.map +1 -0
  68. package/apps/server/dist/infrastructure/persistence/sqlite/SqliteBuildRepository.js +21 -0
  69. package/apps/server/dist/infrastructure/persistence/sqlite/SqliteBuildRepository.js.map +1 -0
  70. package/apps/server/dist/infrastructure/persistence/sqlite/SqliteEntryReadModel.js +11 -0
  71. package/apps/server/dist/infrastructure/persistence/sqlite/SqliteEntryReadModel.js.map +1 -0
  72. package/apps/server/dist/infrastructure/persistence/sqlite/SqliteEntryRepository.js +59 -0
  73. package/apps/server/dist/infrastructure/persistence/sqlite/SqliteEntryRepository.js.map +1 -0
  74. package/apps/server/dist/infrastructure/persistence/sqlite/SqliteSourceRepository.js +47 -0
  75. package/apps/server/dist/infrastructure/persistence/sqlite/SqliteSourceRepository.js.map +1 -0
  76. package/apps/server/dist/infrastructure/persistence/sqlite/connection.js +50 -0
  77. package/apps/server/dist/infrastructure/persistence/sqlite/connection.js.map +1 -0
  78. package/apps/server/dist/infrastructure/search/SearchEngineFactory.js +32 -0
  79. package/apps/server/dist/infrastructure/search/SearchEngineFactory.js.map +1 -0
  80. package/apps/server/dist/infrastructure/search/json/JsonSearchEngine.js +147 -0
  81. package/apps/server/dist/infrastructure/search/json/JsonSearchEngine.js.map +1 -0
  82. package/apps/server/dist/infrastructure/search/vector/EmbeddingService.js +23 -0
  83. package/apps/server/dist/infrastructure/search/vector/EmbeddingService.js.map +1 -0
  84. package/apps/server/dist/infrastructure/search/vector/VectorSearchEngine.js +378 -0
  85. package/apps/server/dist/infrastructure/search/vector/VectorSearchEngine.js.map +1 -0
  86. package/apps/server/dist/infrastructure/source-processors/AntoraSourceProcessor.js +11 -0
  87. package/apps/server/dist/infrastructure/source-processors/AntoraSourceProcessor.js.map +1 -0
  88. package/apps/server/dist/infrastructure/source-processors/AsciidocSourceProcessor.js +9 -0
  89. package/apps/server/dist/infrastructure/source-processors/AsciidocSourceProcessor.js.map +1 -0
  90. package/apps/server/dist/infrastructure/source-processors/DocumentNormalizer.js +11 -0
  91. package/apps/server/dist/infrastructure/source-processors/DocumentNormalizer.js.map +1 -0
  92. package/apps/server/dist/infrastructure/source-processors/GithubMarkdownSourceProcessor.js +9 -0
  93. package/apps/server/dist/infrastructure/source-processors/GithubMarkdownSourceProcessor.js.map +1 -0
  94. package/apps/server/dist/infrastructure/source-processors/MavenSourceProcessor.js +9 -0
  95. package/apps/server/dist/infrastructure/source-processors/MavenSourceProcessor.js.map +1 -0
  96. package/apps/server/dist/infrastructure/source-processors/PathResolver.js +5 -0
  97. package/apps/server/dist/infrastructure/source-processors/PathResolver.js.map +1 -0
  98. package/apps/server/dist/infrastructure/source-processors/SourceCodeSourceProcessor.js +269 -0
  99. package/apps/server/dist/infrastructure/source-processors/SourceCodeSourceProcessor.js.map +1 -0
  100. package/apps/server/dist/infrastructure/source-processors/ZipSourceProcessor.js +9 -0
  101. package/apps/server/dist/infrastructure/source-processors/ZipSourceProcessor.js.map +1 -0
  102. package/apps/server/dist/mcp-http.js +93 -0
  103. package/apps/server/dist/mcp-http.js.map +1 -0
  104. package/apps/server/dist/mcp.js +339 -0
  105. package/apps/server/dist/mcp.js.map +1 -0
  106. package/apps/server/dist/routes/build.js +89 -0
  107. package/apps/server/dist/routes/build.js.map +1 -0
  108. package/apps/server/dist/routes/entries.js +52 -0
  109. package/apps/server/dist/routes/entries.js.map +1 -0
  110. package/apps/server/dist/routes/graph.js +58 -0
  111. package/apps/server/dist/routes/graph.js.map +1 -0
  112. package/apps/server/dist/routes/search.js +24 -0
  113. package/apps/server/dist/routes/search.js.map +1 -0
  114. package/apps/server/dist/routes/sources.js +100 -0
  115. package/apps/server/dist/routes/sources.js.map +1 -0
  116. package/apps/server/dist/routes/viewer.js +22 -0
  117. package/apps/server/dist/routes/viewer.js.map +1 -0
  118. package/apps/server/dist/services/antora.js +222 -0
  119. package/apps/server/dist/services/antora.js.map +1 -0
  120. package/apps/server/dist/services/asciidoc.js +206 -0
  121. package/apps/server/dist/services/asciidoc.js.map +1 -0
  122. package/apps/server/dist/services/configLoader.js +150 -0
  123. package/apps/server/dist/services/configLoader.js.map +1 -0
  124. package/apps/server/dist/services/githubMarkdown.js +221 -0
  125. package/apps/server/dist/services/githubMarkdown.js.map +1 -0
  126. package/apps/server/dist/services/maven.js +148 -0
  127. package/apps/server/dist/services/maven.js.map +1 -0
  128. package/apps/server/dist/services/normalizer.js +42 -0
  129. package/apps/server/dist/services/normalizer.js.map +1 -0
  130. package/apps/server/dist/services/paths.js +5 -0
  131. package/apps/server/dist/services/paths.js.map +1 -0
  132. package/apps/server/dist/services/textExtractor.js +46 -0
  133. package/apps/server/dist/services/textExtractor.js.map +1 -0
  134. package/apps/server/dist/services/zip.js +63 -0
  135. package/apps/server/dist/services/zip.js.map +1 -0
  136. package/apps/server/package.json +38 -0
  137. package/apps/server/src/infrastructure/source-processors/SourceCodeSourceProcessor.ts +9 -2
  138. package/bin/commands/dev.ts +2 -2
  139. package/bin/commands/serve.ts +2 -2
  140. package/package.json +22 -4
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Dockit — Documentation Hub</title>
7
- <script type="module" crossorigin src="/assets/index-CqOXxsEZ.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-DzadxeQH.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-DwvaANnI.css">
9
9
  </head>
10
10
  <body class="antialiased">
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Dockit — Documentation Hub</title>
7
+ </head>
8
+ <body class="antialiased">
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@dockit/client",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "lucide-react": "^0.469.0",
13
+ "react": "^19.0.0",
14
+ "react-dom": "^19.0.0",
15
+ "react-router-dom": "^7.1.1"
16
+ },
17
+ "devDependencies": {
18
+ "@tailwindcss/vite": "^4.0.0",
19
+ "@types/react": "^19.0.3",
20
+ "@types/react-dom": "^19.0.2",
21
+ "@vitejs/plugin-react": "^4.3.4",
22
+ "tailwindcss": "^4.0.0",
23
+ "typescript": "^5.7.3",
24
+ "vite": "^6.0.7"
25
+ }
26
+ }
@@ -0,0 +1,18 @@
1
+ import { Routes, Route } from 'react-router-dom';
2
+ import Layout from './components/Layout';
3
+ import EntryList from './components/EntryList';
4
+ import EntryForm from './components/EntryForm';
5
+ import EntryDetail from './components/EntryDetail';
6
+
7
+ export default function App() {
8
+ return (
9
+ <Layout>
10
+ <Routes>
11
+ <Route path="/" element={<EntryList />} />
12
+ <Route path="/entries/new" element={<EntryForm />} />
13
+ <Route path="/entries/:id/edit" element={<EntryForm />} />
14
+ <Route path="/entries/:id" element={<EntryDetail />} />
15
+ </Routes>
16
+ </Layout>
17
+ );
18
+ }
@@ -0,0 +1,54 @@
1
+ import type { Entry, EntryDetail, Source, SourceConfig, BuildStatusResponse, SearchResult } from '../types';
2
+
3
+ const BASE = '/api';
4
+
5
+ async function request<T>(url: string, options?: RequestInit): Promise<T> {
6
+ const res = await fetch(`${BASE}${url}`, {
7
+ headers: { 'Content-Type': 'application/json' },
8
+ ...options,
9
+ });
10
+ if (!res.ok) {
11
+ const body = await res.json().catch(() => ({ error: res.statusText }));
12
+ throw new Error(body.error || `Request failed: ${res.status}`);
13
+ }
14
+ return res.json();
15
+ }
16
+
17
+ export const api = {
18
+ entries: {
19
+ list: () => request<Entry[]>('/entries'),
20
+ get: (id: string) => request<EntryDetail>(`/entries/${id}`),
21
+ create: (data: { name: string; version: string; description?: string }) =>
22
+ request<Entry>('/entries', { method: 'POST', body: JSON.stringify(data) }),
23
+ update: (id: string, data: { name?: string; version?: string; description?: string }) =>
24
+ request<Entry>(`/entries/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
25
+ delete: (id: string) =>
26
+ request<{ success: boolean }>(`/entries/${id}`, { method: 'DELETE' }),
27
+ },
28
+
29
+ sources: {
30
+ create: (entryId: string, data: { type: string; label: string; config: SourceConfig }) =>
31
+ request<Source>(`/entries/${entryId}/sources`, { method: 'POST', body: JSON.stringify(data) }),
32
+ update: (id: string, data: { label?: string; config?: SourceConfig }) =>
33
+ request<Source>(`/sources/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
34
+ delete: (id: string) =>
35
+ request<{ success: boolean }>(`/sources/${id}`, { method: 'DELETE' }),
36
+ },
37
+
38
+ build: {
39
+ trigger: (entryId: string) =>
40
+ request<{ buildId: string; status: string }>(`/entries/${entryId}/build`, { method: 'POST' }),
41
+ status: (entryId: string) =>
42
+ request<BuildStatusResponse>(`/entries/${entryId}/build-status`),
43
+ cliScript: (entryId: string) =>
44
+ fetch(`${BASE}/entries/${entryId}/cli-script`).then((r) => r.text()),
45
+ },
46
+
47
+ search: (entryId: string, q: string) =>
48
+ request<SearchResult[]>(`/entries/${entryId}/search?q=${encodeURIComponent(q)}`),
49
+
50
+ searchGlobal: (q: string) =>
51
+ request<Array<SearchResult & { entryId: string; entryName: string; entryVersion: string }>>(`/search?q=${encodeURIComponent(q)}`),
52
+
53
+ bundleUrl: (entryId: string) => `${BASE}/bundle/${entryId}/`,
54
+ };
@@ -0,0 +1,77 @@
1
+ import { useEffect, useState, useRef } from 'react';
2
+ import { Terminal, ChevronDown, ChevronRight, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
3
+ import type { BuildStatusResponse } from '../types';
4
+ import { api } from '../api/client';
5
+
6
+ interface Props {
7
+ entryId: string;
8
+ refreshKey: number;
9
+ }
10
+
11
+ export default function BuildPanel({ entryId, refreshKey }: Props) {
12
+ const [status, setStatus] = useState<BuildStatusResponse | null>(null);
13
+ const [open, setOpen] = useState(false);
14
+ const logEndRef = useRef<HTMLDivElement>(null);
15
+ const intervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
16
+
17
+ useEffect(() => {
18
+ const fetch = async () => {
19
+ try {
20
+ const data = await api.build.status(entryId);
21
+ setStatus(data);
22
+ if (data.status === 'building') {
23
+ setOpen(true);
24
+ }
25
+ if (data.status === 'ready' || data.status === 'error') {
26
+ if (intervalRef.current) {
27
+ clearInterval(intervalRef.current);
28
+ }
29
+ }
30
+ } catch {
31
+ // ignored
32
+ }
33
+ };
34
+ fetch();
35
+ intervalRef.current = setInterval(fetch, 1500);
36
+ return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
37
+ }, [entryId, refreshKey]);
38
+
39
+ if (!status || status.status === 'none') return null;
40
+
41
+ const accentColor = status.status === 'ready' ? 'bg-success' :
42
+ status.status === 'error' ? 'bg-danger' : 'bg-warning';
43
+
44
+ return (
45
+ <div className="bg-surface ring-1 ring-border rounded-lg overflow-hidden">
46
+ <button
47
+ onClick={() => setOpen(!open)}
48
+ className="w-full flex items-center gap-2 px-3 py-2 hover:bg-bg-alt transition-colors text-left"
49
+ >
50
+ {open ? <ChevronDown size={14} className="text-text-muted" /> : <ChevronRight size={14} className="text-text-muted" />}
51
+ <Terminal size={14} className="text-text-dim" />
52
+ <span className="text-xs font-medium text-text">Build Log</span>
53
+ <span className="flex items-center gap-1 ml-auto">
54
+ {status.status === 'building' && <Loader2 size={12} className="animate-spin text-warning" />}
55
+ {status.status === 'ready' && <CheckCircle size={12} className="text-success" />}
56
+ {status.status === 'error' && <AlertCircle size={12} className="text-danger" />}
57
+ <span className={`text-[11px] font-medium ${
58
+ status.status === 'ready' ? 'text-success' :
59
+ status.status === 'error' ? 'text-danger' :
60
+ 'text-warning'
61
+ }`}>
62
+ {status.status.charAt(0).toUpperCase() + status.status.slice(1)}
63
+ </span>
64
+ </span>
65
+ </button>
66
+ {open && (
67
+ <div className="relative">
68
+ <div className={`absolute left-0 top-0 bottom-0 w-0.5 ${accentColor}`} />
69
+ <div className="bg-terminal-bg text-terminal-fg p-3 pl-4 max-h-48 overflow-auto font-mono text-[11px] leading-relaxed">
70
+ <pre className="whitespace-pre-wrap">{status.log || 'Waiting for build to start...'}</pre>
71
+ <div ref={logEndRef} />
72
+ </div>
73
+ </div>
74
+ )}
75
+ </div>
76
+ );
77
+ }
@@ -0,0 +1,76 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { ExternalLink, Maximize2, Minimize2, FileWarning, FileText } from 'lucide-react';
3
+ import { api } from '../api/client';
4
+
5
+ interface Props {
6
+ entryId: string;
7
+ selectedFile?: string;
8
+ }
9
+
10
+ export default function DocViewer({ entryId, selectedFile }: Props) {
11
+ const [expanded, setExpanded] = useState(false);
12
+ const [error, setError] = useState(false);
13
+ const url = selectedFile
14
+ ? `${api.bundleUrl(entryId)}${selectedFile}`
15
+ : api.bundleUrl(entryId);
16
+
17
+ useEffect(() => { setError(false); }, [selectedFile]);
18
+
19
+ if (!selectedFile) {
20
+ return (
21
+ <div className="flex flex-col items-center justify-center h-full gap-3 text-center">
22
+ <div className="w-14 h-14 rounded-2xl bg-bg-alt flex items-center justify-center">
23
+ <FileText size={26} className="text-text-muted" />
24
+ </div>
25
+ <div>
26
+ <p className="text-sm text-text-dim font-medium">No document selected</p>
27
+ <p className="text-xs text-text-muted mt-1">Search for a document and click a result to view it here.</p>
28
+ </div>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ if (error) {
34
+ return (
35
+ <div className="flex flex-col items-center justify-center h-full gap-3 text-center">
36
+ <div className="w-14 h-14 rounded-2xl bg-bg-alt flex items-center justify-center">
37
+ <FileWarning size={26} className="text-text-muted" />
38
+ </div>
39
+ <div>
40
+ <p className="text-sm text-text-dim font-medium">Could not load document</p>
41
+ <p className="text-xs text-text-muted mt-1">The documentation may not be built yet. Click &ldquo;Build&rdquo; to generate it.</p>
42
+ </div>
43
+ </div>
44
+ );
45
+ }
46
+
47
+ return (
48
+ <div className={`flex flex-col h-full ${expanded ? 'fixed inset-0 z-40 bg-bg p-4' : ''}`}>
49
+ <div className="flex items-center gap-2 mb-2 shrink-0">
50
+ <span className="text-xs text-text-muted font-mono truncate flex-1">{selectedFile}</span>
51
+ <button
52
+ onClick={() => setExpanded(!expanded)}
53
+ className="p-1 rounded text-text-muted hover:text-text hover:bg-bg-alt transition-colors"
54
+ title={expanded ? 'Exit fullscreen' : 'Fullscreen'}
55
+ >
56
+ {expanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
57
+ </button>
58
+ <a
59
+ href={url}
60
+ target="_blank"
61
+ rel="noopener noreferrer"
62
+ className="p-1 rounded text-text-muted hover:text-text hover:bg-bg-alt transition-colors"
63
+ title="Open in new tab"
64
+ >
65
+ <ExternalLink size={14} />
66
+ </a>
67
+ </div>
68
+ <iframe
69
+ src={url}
70
+ onError={() => setError(true)}
71
+ className="flex-1 w-full ring-1 ring-border rounded-lg bg-surface min-h-0"
72
+ title="Documentation Viewer"
73
+ />
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,322 @@
1
+ import { useEffect, useState, useCallback } from 'react';
2
+ import { useParams, useNavigate, Link, useSearchParams } from 'react-router-dom';
3
+ import {
4
+ ArrowLeft, Pencil, Trash2, Play, Download, Plus,
5
+ Package, GitBranch, FileArchive, FileText, Github, Loader2,
6
+ CheckCircle, AlertCircle, Clock, MoreHorizontal, FileCode, Network,
7
+ } from 'lucide-react';
8
+ import type { EntryDetail as EntryDetailType, Source, SourceType, SourceConfig } from '../types';
9
+ import { api } from '../api/client';
10
+ import SourceForm from './SourceForm';
11
+ import BuildPanel from './BuildPanel';
12
+ import SearchBar from './SearchBar';
13
+ import DocViewer from './DocViewer';
14
+
15
+ const TYPE_ICONS: Record<SourceType, typeof Package> = {
16
+ zip: FileArchive,
17
+ antora: GitBranch,
18
+ maven: Package,
19
+ asciidoc: FileText,
20
+ 'github-markdown': Github,
21
+ 'source-code': FileCode,
22
+ };
23
+
24
+ const TYPE_LABELS: Record<SourceType, string> = {
25
+ zip: 'ZIP',
26
+ antora: 'Antora',
27
+ maven: 'Maven',
28
+ asciidoc: 'AsciiDoc',
29
+ 'github-markdown': 'GitHub Markdown',
30
+ 'source-code': 'Source Code',
31
+ };
32
+
33
+ const statusConfig: Record<string, { icon: typeof CheckCircle; color: string; bg: string; label: string }> = {
34
+ pending: { icon: Clock, color: 'text-text-muted', bg: 'bg-bg-alt', label: 'Pending' },
35
+ building: { icon: Loader2, color: 'text-warning', bg: 'bg-warning/5', label: 'Building' },
36
+ ready: { icon: CheckCircle, color: 'text-success', bg: 'bg-success/5', label: 'Ready' },
37
+ error: { icon: AlertCircle, color: 'text-danger', bg: 'bg-danger/5', label: 'Error' },
38
+ };
39
+
40
+ export default function EntryDetail() {
41
+ const { id } = useParams<{ id: string }>();
42
+ const navigate = useNavigate();
43
+ const [searchParams] = useSearchParams();
44
+
45
+ const [entry, setEntry] = useState<EntryDetailType | null>(null);
46
+ const [loading, setLoading] = useState(true);
47
+ const [error, setError] = useState<string | null>(null);
48
+ const [sourceFormOpen, setSourceFormOpen] = useState(false);
49
+ const [editingSource, setEditingSource] = useState<Source | null>(null);
50
+ const [formRevision, setFormRevision] = useState(0);
51
+ const [buildKey, setBuildKey] = useState(0);
52
+ const [sourceMenuOpen, setSourceMenuOpen] = useState<string | null>(null);
53
+ const [selectedFile, setSelectedFile] = useState<string | undefined>(undefined);
54
+
55
+ const fetchEntry = useCallback(async () => {
56
+ if (!id) return;
57
+ try {
58
+ const data = await api.entries.get(id);
59
+ setEntry(data);
60
+ setError(null);
61
+ } catch (err) {
62
+ setError((err as Error).message);
63
+ } finally {
64
+ setLoading(false);
65
+ }
66
+ }, [id]);
67
+
68
+ useEffect(() => { fetchEntry(); }, [fetchEntry]);
69
+
70
+ useEffect(() => {
71
+ const doc = searchParams.get('doc');
72
+ if (doc) {
73
+ setSelectedFile(doc);
74
+ }
75
+ }, [searchParams]);
76
+
77
+ const handleDeleteEntry = async () => {
78
+ if (!entry) return;
79
+ if (!confirm(`Delete entry "${entry.name}" and all its data permanently?`)) return;
80
+ try {
81
+ await api.entries.delete(entry.id);
82
+ navigate('/');
83
+ } catch (err) {
84
+ alert(`Failed to delete: ${(err as Error).message}`);
85
+ }
86
+ };
87
+
88
+ const handleAddSource = async (data: { type: SourceType; label: string; config: SourceConfig }) => {
89
+ if (!entry) return;
90
+ await api.sources.create(entry.id, data);
91
+ await fetchEntry();
92
+ };
93
+
94
+ const handleEditSource = async (data: { type: SourceType; label: string; config: SourceConfig }) => {
95
+ if (!editingSource) return;
96
+ await api.sources.update(editingSource.id, { label: data.label, config: data.config });
97
+ setEditingSource(null);
98
+ await fetchEntry();
99
+ };
100
+
101
+ const handleDeleteSource = async (source: Source) => {
102
+ if (!confirm(`Remove source "${source.label}"?`)) return;
103
+ await api.sources.delete(source.id);
104
+ await fetchEntry();
105
+ };
106
+
107
+ const handleBuild = async () => {
108
+ if (!entry) return;
109
+ try {
110
+ await api.build.trigger(entry.id);
111
+ setBuildKey((k) => k + 1);
112
+ await fetchEntry();
113
+ } catch (err) {
114
+ alert(`Build failed: ${(err as Error).message}`);
115
+ }
116
+ };
117
+
118
+ const handleDownloadScript = async () => {
119
+ if (!entry) return;
120
+ try {
121
+ const script = await api.build.cliScript(entry.id);
122
+ const blob = new Blob([script], { type: 'text/plain' });
123
+ const url = URL.createObjectURL(blob);
124
+ const a = document.createElement('a');
125
+ a.href = url;
126
+ const slug = entry.name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
127
+ a.download = `dockit-build-${slug}.sh`;
128
+ a.click();
129
+ URL.revokeObjectURL(url);
130
+ } catch (err) {
131
+ alert(`Failed to generate script: ${(err as Error).message}`);
132
+ }
133
+ };
134
+
135
+ if (loading) {
136
+ return (
137
+ <div className="flex items-center justify-center h-full">
138
+ <Loader2 size={24} className="animate-spin text-text-muted" />
139
+ </div>
140
+ );
141
+ }
142
+
143
+ if (error || !entry) {
144
+ return (
145
+ <div className="flex flex-col items-center justify-center h-full gap-3">
146
+ <AlertCircle size={32} className="text-danger" />
147
+ <p className="text-sm text-danger">{error || 'Entry not found'}</p>
148
+ <Link to="/" className="text-sm text-primary hover:underline">Back to entries</Link>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ const status = statusConfig[entry.status] || statusConfig.pending;
154
+ const StatusIcon = status.icon;
155
+
156
+ return (
157
+ <div className="flex h-full">
158
+ {/* Left panel: config */}
159
+ <div className="w-[420px] shrink-0 border-r border-border overflow-auto p-5">
160
+ {/* Header */}
161
+ <div className="flex items-center gap-2 mb-4">
162
+ <button
163
+ onClick={() => navigate('/')}
164
+ className="p-1.5 rounded-md text-text-dim hover:text-text hover:bg-bg-alt transition-colors"
165
+ >
166
+ <ArrowLeft size={16} />
167
+ </button>
168
+ <div className="flex-1 min-w-0">
169
+ <h1 className="text-lg font-semibold text-text truncate">{entry.name}</h1>
170
+ </div>
171
+ <Link
172
+ to={`/entries/${entry.id}/edit`}
173
+ className="p-1.5 rounded-md text-text-dim hover:text-text hover:bg-bg-alt transition-colors"
174
+ title="Edit"
175
+ >
176
+ <Pencil size={14} />
177
+ </Link>
178
+ <button
179
+ onClick={handleDeleteEntry}
180
+ className="p-1.5 rounded-md text-text-dim hover:text-danger hover:bg-danger/5 transition-colors"
181
+ title="Delete"
182
+ >
183
+ <Trash2 size={14} />
184
+ </button>
185
+ </div>
186
+
187
+ {/* Meta */}
188
+ <div className="flex items-center gap-3 mb-4">
189
+ <span className="text-xs text-text-dim font-mono bg-bg-alt px-2 py-0.5 rounded">{entry.version}</span>
190
+ <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${status.color} ${status.bg}`}>
191
+ <StatusIcon size={11} className={entry.status === 'building' ? 'animate-spin' : ''} />
192
+ {status.label}
193
+ </span>
194
+ </div>
195
+
196
+ {entry.description && (
197
+ <p className="text-sm text-text-dim mb-4 leading-relaxed">{entry.description}</p>
198
+ )}
199
+
200
+ {/* Actions */}
201
+ <div className="flex gap-2 mb-5">
202
+ <button
203
+ onClick={handleBuild}
204
+ disabled={entry.status === 'building'}
205
+ className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary-hover transition-colors disabled:opacity-50"
206
+ >
207
+ <Play size={14} />
208
+ Build
209
+ </button>
210
+ <button
211
+ onClick={handleDownloadScript}
212
+ className="flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium ring-1 ring-border text-text-dim hover:bg-bg-alt hover:text-text transition-colors"
213
+ title="Download CLI script"
214
+ >
215
+ <Download size={14} />
216
+ </button>
217
+ </div>
218
+
219
+ {/* Sources */}
220
+ <div className="mb-4">
221
+ <div className="flex items-center justify-between mb-2.5">
222
+ <h3 className="text-xs font-semibold text-text-dim uppercase tracking-wider">
223
+ Sources ({entry.sources.length})
224
+ </h3>
225
+ <button
226
+ onClick={() => { setEditingSource(null); setFormRevision(r => r + 1); setSourceFormOpen(true); }}
227
+ className="flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium text-primary hover:bg-primary/10 transition-colors"
228
+ >
229
+ <Plus size={12} />
230
+ Add
231
+ </button>
232
+ </div>
233
+
234
+ {entry.sources.length === 0 ? (
235
+ <div className="bg-bg-alt ring-1 ring-border rounded-lg p-4 text-center">
236
+ <p className="text-xs text-text-muted">No sources yet</p>
237
+ </div>
238
+ ) : (
239
+ <div className="space-y-1.5">
240
+ {entry.sources.map((source) => {
241
+ const Icon = TYPE_ICONS[source.type];
242
+ return (
243
+ <div key={source.id} className="flex items-center gap-2.5 bg-surface ring-1 ring-border rounded-lg px-3 py-2 group/src">
244
+ <Icon size={14} className="text-text-muted shrink-0" />
245
+ <div className="flex-1 min-w-0">
246
+ <p className="text-sm font-medium text-text truncate">{source.label}</p>
247
+ <p className="text-[11px] text-text-muted font-mono truncate">{getConfigSummary(source)}</p>
248
+ </div>
249
+ <span className="text-[10px] text-text-muted uppercase tracking-wider shrink-0">{TYPE_LABELS[source.type]}</span>
250
+ {Boolean((source.config as Record<string, unknown>)?.graphifyEnabled) && (
251
+ <Network size={11} className="text-accent shrink-0" aria-label="Knowledge graph enabled" />
252
+ )}
253
+ <div className="relative">
254
+ <button
255
+ onClick={() => setSourceMenuOpen(sourceMenuOpen === source.id ? null : source.id)}
256
+ className="p-1 rounded text-text-muted hover:text-text opacity-0 group-hover/src:opacity-100 transition-all"
257
+ >
258
+ <MoreHorizontal size={13} />
259
+ </button>
260
+ {sourceMenuOpen === source.id && (
261
+ <>
262
+ <div className="fixed inset-0 z-10" onClick={() => setSourceMenuOpen(null)} />
263
+ <div className="absolute right-0 top-full mt-1 z-20 bg-surface ring-1 ring-border rounded-lg shadow-lg py-1 w-28">
264
+ <button
265
+ onClick={() => { setEditingSource(source); setFormRevision(r => r + 1); setSourceFormOpen(true); setSourceMenuOpen(null); }}
266
+ className="w-full text-left px-3 py-1.5 text-xs text-text hover:bg-bg-alt transition-colors"
267
+ >
268
+ Edit
269
+ </button>
270
+ <button
271
+ onClick={() => { handleDeleteSource(source); setSourceMenuOpen(null); }}
272
+ className="w-full text-left px-3 py-1.5 text-xs text-danger hover:bg-danger/5 transition-colors"
273
+ >
274
+ Remove
275
+ </button>
276
+ </div>
277
+ </>
278
+ )}
279
+ </div>
280
+ </div>
281
+ );
282
+ })}
283
+ </div>
284
+ )}
285
+ </div>
286
+
287
+ {/* Build Log */}
288
+ <BuildPanel entryId={entry.id} refreshKey={buildKey} />
289
+ </div>
290
+
291
+ {/* Right panel: search + viewer */}
292
+ <div className="flex-1 flex flex-col overflow-hidden">
293
+ <div className="p-4 border-b border-border">
294
+ <SearchBar entryId={entry.id} onSelectFile={setSelectedFile} scopeLabel="Entry only" />
295
+ </div>
296
+ <div className="flex-1 overflow-auto p-4">
297
+ <DocViewer entryId={entry.id} selectedFile={selectedFile} />
298
+ </div>
299
+ </div>
300
+
301
+ <SourceForm
302
+ key={formRevision}
303
+ open={sourceFormOpen}
304
+ onClose={() => { setSourceFormOpen(false); setEditingSource(null); }}
305
+ onCreate={editingSource ? handleEditSource : handleAddSource}
306
+ initial={editingSource ? { type: editingSource.type, label: editingSource.label, config: editingSource.config } : undefined}
307
+ />
308
+ </div>
309
+ );
310
+ }
311
+
312
+ function getConfigSummary(source: Source): string {
313
+ const c = source.config as Record<string, string>;
314
+ switch (source.type) {
315
+ case 'zip': return c.localPath || c.url || '';
316
+ case 'maven': return `${c.groupId || '?'}:${c.artifactId || '?'}:${c.version || '?'}`;
317
+ case 'antora': return c.localPath || c.repoUrl || c.zipPath || '';
318
+ case 'asciidoc': return c.localPath || c.repoUrl || c.zipPath || '';
319
+ case 'github-markdown': return c.localPath || c.repoUrl || '';
320
+ case 'source-code': return c.localPath || c.repoUrl || c.zipPath || '';
321
+ }
322
+ }