@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
@@ -0,0 +1,117 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useNavigate, useParams } from 'react-router-dom';
3
+ import { ArrowLeft, Save } from 'lucide-react';
4
+ import { api } from '../api/client';
5
+
6
+ export default function EntryForm() {
7
+ const { id } = useParams<{ id: string }>();
8
+ const isEdit = Boolean(id);
9
+ const navigate = useNavigate();
10
+
11
+ const [name, setName] = useState('');
12
+ const [version, setVersion] = useState('');
13
+ const [description, setDescription] = useState('');
14
+ const [saving, setSaving] = useState(false);
15
+ const [error, setError] = useState<string | null>(null);
16
+ const [loading, setLoading] = useState(isEdit);
17
+
18
+ useEffect(() => {
19
+ if (!id) return;
20
+ api.entries.get(id).then((data) => {
21
+ setName(data.name);
22
+ setVersion(data.version);
23
+ setDescription(data.description);
24
+ }).catch((err) => setError((err as Error).message))
25
+ .finally(() => setLoading(false));
26
+ }, [id]);
27
+
28
+ const handleSubmit = async (e: React.FormEvent) => {
29
+ e.preventDefault();
30
+ if (!name.trim() || !version.trim()) {
31
+ setError('Name and version are required');
32
+ return;
33
+ }
34
+ setSaving(true);
35
+ setError(null);
36
+ try {
37
+ if (isEdit && id) {
38
+ await api.entries.update(id, { name: name.trim(), version: version.trim(), description: description.trim() });
39
+ } else {
40
+ await api.entries.create({ name: name.trim(), version: version.trim(), description: description.trim() });
41
+ }
42
+ navigate('/');
43
+ } catch (err) {
44
+ setError((err as Error).message);
45
+ } finally {
46
+ setSaving(false);
47
+ }
48
+ };
49
+
50
+ if (loading) {
51
+ return <div className="py-20 text-center text-sm text-text-muted">Loading...</div>;
52
+ }
53
+
54
+ return (
55
+ <div className="p-6 md:p-8 max-w-xl">
56
+ <button
57
+ onClick={() => navigate(-1)}
58
+ className="flex items-center gap-1.5 text-sm text-text-dim hover:text-text mb-6 transition-colors"
59
+ >
60
+ <ArrowLeft size={16} />
61
+ Back
62
+ </button>
63
+
64
+ <h1 className="text-xl font-semibold text-text mb-6">
65
+ {isEdit ? 'Edit Entry' : 'Create New Entry'}
66
+ </h1>
67
+
68
+ <form onSubmit={handleSubmit} className="space-y-5">
69
+ <div>
70
+ <label className="block text-sm font-medium text-text mb-2">Name</label>
71
+ <input
72
+ type="text"
73
+ value={name}
74
+ onChange={(e) => setName(e.target.value)}
75
+ placeholder="e.g. Quarkus"
76
+ className="w-full px-3.5 py-2.5 bg-surface ring-1 ring-border rounded-lg text-sm text-text placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 transition-all"
77
+ />
78
+ </div>
79
+ <div>
80
+ <label className="block text-sm font-medium text-text mb-2">Version</label>
81
+ <input
82
+ type="text"
83
+ value={version}
84
+ onChange={(e) => setVersion(e.target.value)}
85
+ placeholder="e.g. 3.8.0"
86
+ className="w-full px-3.5 py-2.5 bg-surface ring-1 ring-border rounded-lg text-sm text-text placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 transition-all"
87
+ />
88
+ </div>
89
+ <div>
90
+ <label className="block text-sm font-medium text-text mb-2">Description <span className="text-text-muted font-normal">(optional)</span></label>
91
+ <textarea
92
+ value={description}
93
+ onChange={(e) => setDescription(e.target.value)}
94
+ placeholder="Brief description of this documentation entry"
95
+ rows={3}
96
+ className="w-full px-3.5 py-2.5 bg-surface ring-1 ring-border rounded-lg text-sm text-text placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 transition-all resize-none"
97
+ />
98
+ </div>
99
+
100
+ {error && (
101
+ <div className="px-3 py-2 bg-danger/5 ring-1 ring-danger/20 rounded-lg text-sm text-danger">
102
+ {error}
103
+ </div>
104
+ )}
105
+
106
+ <button
107
+ type="submit"
108
+ disabled={saving}
109
+ className="flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary-hover transition-colors disabled:opacity-50"
110
+ >
111
+ <Save size={16} />
112
+ {saving ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Entry'}
113
+ </button>
114
+ </form>
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,165 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Link, useNavigate } from 'react-router-dom';
3
+ import { Plus, BookOpen, Clock, CheckCircle, AlertCircle, Loader2, Trash2, Package } from 'lucide-react';
4
+ import type { Entry } from '../types';
5
+ import { api } from '../api/client';
6
+
7
+ const statusConfig: Record<string, { icon: typeof CheckCircle; color: string; bg: string; label: string }> = {
8
+ pending: { icon: Clock, color: 'text-text-muted', bg: 'bg-bg-alt', label: 'Pending' },
9
+ building: { icon: Loader2, color: 'text-warning', bg: 'bg-warning/5', label: 'Building' },
10
+ ready: { icon: CheckCircle, color: 'text-success', bg: 'bg-success/5', label: 'Ready' },
11
+ error: { icon: AlertCircle, color: 'text-danger', bg: 'bg-danger/5', label: 'Error' },
12
+ };
13
+
14
+ export default function EntryList() {
15
+ const [entries, setEntries] = useState<Entry[]>([]);
16
+ const [loading, setLoading] = useState(true);
17
+ const [error, setError] = useState<string | null>(null);
18
+ const navigate = useNavigate();
19
+
20
+ const fetchEntries = async () => {
21
+ try {
22
+ setLoading(true);
23
+ const data = await api.entries.list();
24
+ setEntries(data);
25
+ setError(null);
26
+ } catch (err) {
27
+ setError((err as Error).message);
28
+ } finally {
29
+ setLoading(false);
30
+ }
31
+ };
32
+
33
+ useEffect(() => { fetchEntries(); }, []);
34
+
35
+ const handleDelete = async (e: React.MouseEvent, id: string, name: string) => {
36
+ e.stopPropagation();
37
+ if (!confirm(`Delete entry "${name}" and all its data?`)) return;
38
+ try {
39
+ await api.entries.delete(id);
40
+ setEntries((prev) => prev.filter((e) => e.id !== id));
41
+ } catch (err) {
42
+ alert(`Failed to delete: ${(err as Error).message}`);
43
+ }
44
+ };
45
+
46
+ if (loading) {
47
+ return (
48
+ <div className="flex items-center justify-center h-full">
49
+ <Loader2 size={24} className="animate-spin text-text-muted" />
50
+ </div>
51
+ );
52
+ }
53
+
54
+ if (error) {
55
+ return (
56
+ <div className="flex flex-col items-center justify-center h-full gap-3">
57
+ <AlertCircle size={32} className="text-danger" />
58
+ <p className="text-sm text-danger">{error}</p>
59
+ <button onClick={fetchEntries} className="text-sm text-primary hover:underline">Retry</button>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ if (entries.length === 0) {
65
+ return (
66
+ <div className="flex flex-col items-center justify-center h-full gap-5">
67
+ <div className="w-16 h-16 rounded-2xl bg-bg-alt flex items-center justify-center">
68
+ <BookOpen size={28} className="text-text-muted" />
69
+ </div>
70
+ <div className="text-center max-w-xs">
71
+ <h2 className="text-lg font-semibold text-text">No documentation entries yet</h2>
72
+ <p className="text-sm text-text-dim mt-1.5">Create your first entry to start building your documentation hub.</p>
73
+ </div>
74
+ <Link
75
+ to="/entries/new"
76
+ className="flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary-hover transition-colors"
77
+ >
78
+ <Plus size={16} />
79
+ New Entry
80
+ </Link>
81
+ </div>
82
+ );
83
+ }
84
+
85
+ return (
86
+ <div className="p-6 md:p-8">
87
+ <div className="flex items-center justify-between mb-6">
88
+ <div>
89
+ <h1 className="text-xl font-semibold text-text">Documentation Hub</h1>
90
+ <p className="text-sm text-text-dim mt-0.5">{entries.length} entr{entries.length === 1 ? 'y' : 'ies'}</p>
91
+ </div>
92
+ <Link
93
+ to="/entries/new"
94
+ className="flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary-hover transition-colors"
95
+ >
96
+ <Plus size={15} />
97
+ New Entry
98
+ </Link>
99
+ </div>
100
+
101
+ <div className="ring-1 ring-border rounded-xl overflow-hidden">
102
+ <table className="w-full">
103
+ <thead>
104
+ <tr className="bg-bg-alt text-xs text-text-dim uppercase tracking-wider">
105
+ <th className="text-left px-4 py-2.5 font-medium">Name</th>
106
+ <th className="text-left px-4 py-2.5 font-medium w-24">Version</th>
107
+ <th className="text-left px-4 py-2.5 font-medium w-20">Status</th>
108
+ <th className="text-left px-4 py-2.5 font-medium w-20">Sources</th>
109
+ <th className="text-left px-4 py-2.5 font-medium hidden md:table-cell">Description</th>
110
+ <th className="w-10"></th>
111
+ </tr>
112
+ </thead>
113
+ <tbody>
114
+ {entries.map((entry) => {
115
+ const status = statusConfig[entry.status] || statusConfig.pending;
116
+ const StatusIcon = status.icon;
117
+ const animClass = entry.status === 'building' ? 'animate-spin' : '';
118
+ return (
119
+ <tr
120
+ key={entry.id}
121
+ onClick={() => navigate(`/entries/${entry.id}`)}
122
+ className="border-t border-border hover:bg-bg-alt/50 cursor-pointer transition-colors group"
123
+ >
124
+ <td className="px-4 py-3">
125
+ <span className="text-sm font-medium text-text group-hover:text-primary transition-colors">
126
+ {entry.name}
127
+ </span>
128
+ </td>
129
+ <td className="px-4 py-3">
130
+ <span className="text-xs text-text-dim font-mono">{entry.version}</span>
131
+ </td>
132
+ <td className="px-4 py-3">
133
+ <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${status.color} ${status.bg}`}>
134
+ <StatusIcon size={11} className={animClass} />
135
+ {status.label}
136
+ </span>
137
+ </td>
138
+ <td className="px-4 py-3">
139
+ <span className="text-xs text-text-dim">
140
+ {entry.source_count ?? 0}
141
+ </span>
142
+ </td>
143
+ <td className="px-4 py-3 hidden md:table-cell">
144
+ <span className="text-xs text-text-dim truncate block max-w-xs">
145
+ {entry.description || '—'}
146
+ </span>
147
+ </td>
148
+ <td className="px-2 py-3">
149
+ <button
150
+ onClick={(e) => handleDelete(e, entry.id, entry.name)}
151
+ className="p-1 rounded text-text-muted hover:text-danger opacity-0 group-hover:opacity-100 transition-all"
152
+ title="Delete"
153
+ >
154
+ <Trash2 size={14} />
155
+ </button>
156
+ </td>
157
+ </tr>
158
+ );
159
+ })}
160
+ </tbody>
161
+ </table>
162
+ </div>
163
+ </div>
164
+ );
165
+ }
@@ -0,0 +1,166 @@
1
+ import { useState, useCallback, useEffect, useRef } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { Search, FileText, X } from 'lucide-react';
4
+
5
+ interface GlobalResult {
6
+ entryId: string;
7
+ entryName: string;
8
+ entryVersion: string;
9
+ path: string;
10
+ title: string;
11
+ headings: string[];
12
+ snippet: string;
13
+ }
14
+
15
+ export default function GlobalSearchBar() {
16
+ const navigate = useNavigate();
17
+ const [query, setQuery] = useState('');
18
+ const [results, setResults] = useState<GlobalResult[]>([]);
19
+ const [loading, setLoading] = useState(false);
20
+ const [open, setOpen] = useState(false);
21
+ const [focused, setFocused] = useState(false);
22
+ const [debounceTimer, setDebounceTimer] = useState<ReturnType<typeof setTimeout>>();
23
+ const inputRef = useRef<HTMLInputElement>(null);
24
+ const containerRef = useRef<HTMLDivElement>(null);
25
+
26
+ const doSearch = useCallback(async (q: string) => {
27
+ if (!q.trim()) {
28
+ setResults([]);
29
+ setOpen(false);
30
+ return;
31
+ }
32
+ setLoading(true);
33
+ try {
34
+ const data = await fetch(`/api/search?q=${encodeURIComponent(q)}`).then((r) => r.json());
35
+ setResults(data);
36
+ setOpen(true);
37
+ } catch {
38
+ // ignore
39
+ } finally {
40
+ setLoading(false);
41
+ }
42
+ }, []);
43
+
44
+ const handleChange = (value: string) => {
45
+ setQuery(value);
46
+ if (debounceTimer) clearTimeout(debounceTimer);
47
+ const timer = setTimeout(() => doSearch(value), 300);
48
+ setDebounceTimer(timer);
49
+ };
50
+
51
+ useEffect(() => {
52
+ const handler = (e: KeyboardEvent) => {
53
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
54
+ e.preventDefault();
55
+ inputRef.current?.focus();
56
+ }
57
+ if (e.key === 'Escape') {
58
+ setOpen(false);
59
+ inputRef.current?.blur();
60
+ }
61
+ };
62
+ window.addEventListener('keydown', handler);
63
+ return () => window.removeEventListener('keydown', handler);
64
+ }, []);
65
+
66
+ useEffect(() => {
67
+ const handler = (e: MouseEvent) => {
68
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
69
+ setOpen(false);
70
+ }
71
+ };
72
+ document.addEventListener('mousedown', handler);
73
+ return () => document.removeEventListener('mousedown', handler);
74
+ }, []);
75
+
76
+ const handleSelect = (result: GlobalResult) => {
77
+ setOpen(false);
78
+ setQuery('');
79
+ navigate(`/entries/${result.entryId}?doc=${encodeURIComponent(result.path)}`);
80
+ };
81
+
82
+ const groupedResults: Array<{ entryId: string; entryName: string; entryVersion: string; items: GlobalResult[] }> = [];
83
+ for (const r of results) {
84
+ let group = groupedResults.find((g) => g.entryId === r.entryId);
85
+ if (!group) {
86
+ group = { entryId: r.entryId, entryName: r.entryName, entryVersion: r.entryVersion, items: [] };
87
+ groupedResults.push(group);
88
+ }
89
+ group.items.push(r);
90
+ }
91
+
92
+ return (
93
+ <div ref={containerRef} className="relative flex-1 max-w-xl">
94
+ <div className="relative">
95
+ <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
96
+ <input
97
+ ref={inputRef}
98
+ type="text"
99
+ value={query}
100
+ onChange={(e) => handleChange(e.target.value)}
101
+ onFocus={() => { setFocused(true); if (results.length > 0) setOpen(true); }}
102
+ placeholder="Search all docs..."
103
+ className="w-full pl-9 pr-16 py-1.5 bg-surface ring-1 ring-border rounded-md text-sm text-text placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all"
104
+ />
105
+ {loading && (
106
+ <div className="absolute right-12 top-1/2 -translate-y-1/2">
107
+ <div className="w-3.5 h-3.5 border-2 border-border border-t-primary rounded-full animate-spin" />
108
+ </div>
109
+ )}
110
+ {query && (
111
+ <button
112
+ onClick={() => { setQuery(''); setResults([]); setOpen(false); }}
113
+ className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-muted hover:text-text transition-colors"
114
+ >
115
+ <X size={13} />
116
+ </button>
117
+ )}
118
+ {!query && (
119
+ <div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
120
+ <kbd className="hidden sm:inline-flex items-center px-1.5 py-0.5 text-[10px] font-mono text-text-muted bg-bg-alt ring-1 ring-border rounded">
121
+ ⌘K
122
+ </kbd>
123
+ </div>
124
+ )}
125
+ </div>
126
+
127
+ {open && groupedResults.length > 0 && (
128
+ <>
129
+ <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
130
+ <div className="absolute left-0 right-0 z-50 top-full mt-1.5 bg-surface ring-1 ring-border rounded-xl shadow-xl max-h-96 overflow-auto">
131
+ {groupedResults.map((group) => (
132
+ <div key={group.entryId}>
133
+ <div className="px-3 py-1.5 text-[11px] font-semibold text-text-muted uppercase tracking-wider bg-bg-alt/50 border-b border-border">
134
+ {group.entryName} <span className="font-normal lowercase text-text-dim">({group.entryVersion})</span>
135
+ </div>
136
+ {group.items.map((r, i) => (
137
+ <button
138
+ key={`${group.entryId}-${i}`}
139
+ type="button"
140
+ onClick={() => handleSelect(r)}
141
+ className="flex items-start gap-2.5 px-3 py-2.5 hover:bg-bg-alt transition-colors border-b border-border last:border-b-0 w-full text-left cursor-pointer"
142
+ >
143
+ <FileText size={14} className="text-text-muted mt-0.5 shrink-0" />
144
+ <div className="min-w-0 flex-1">
145
+ <div className="text-sm font-medium text-text truncate">{r.title}</div>
146
+ <div className="text-xs text-text-dim mt-0.5 line-clamp-2">{r.snippet}</div>
147
+ </div>
148
+ </button>
149
+ ))}
150
+ </div>
151
+ ))}
152
+ </div>
153
+ </>
154
+ )}
155
+
156
+ {open && query && !loading && results.length === 0 && (
157
+ <>
158
+ <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
159
+ <div className="absolute left-0 right-0 z-50 top-full mt-1.5 bg-surface ring-1 ring-border rounded-xl shadow-xl p-4 text-center text-sm text-text-dim">
160
+ No results found for &ldquo;{query}&rdquo;
161
+ </div>
162
+ </>
163
+ )}
164
+ </div>
165
+ );
166
+ }
@@ -0,0 +1,57 @@
1
+ import { Link, useLocation } from 'react-router-dom';
2
+ import { BookOpen, Sun, Moon, Plus } from 'lucide-react';
3
+ import { useTheme } from '../hooks/useTheme';
4
+ import GlobalSearchBar from './GlobalSearchBar';
5
+
6
+ export default function Layout({ children }: { children: React.ReactNode }) {
7
+ const location = useLocation();
8
+ const { theme, toggleTheme } = useTheme();
9
+
10
+ return (
11
+ <div className="flex flex-col h-screen overflow-hidden">
12
+ <header className="h-12 bg-bg-alt border-b border-border flex items-center px-4 gap-4 shrink-0">
13
+ <Link to="/" className="flex items-center gap-2 no-underline shrink-0">
14
+ <BookOpen size={18} className="text-primary" />
15
+ <span className="font-semibold text-sm tracking-tight text-text">Dockit</span>
16
+ </Link>
17
+
18
+ <nav className="flex items-center gap-1">
19
+ <Link
20
+ to="/"
21
+ className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
22
+ location.pathname === '/'
23
+ ? 'bg-primary/10 text-primary'
24
+ : 'text-text-dim hover:text-text'
25
+ }`}
26
+ >
27
+ Entries
28
+ </Link>
29
+ </nav>
30
+
31
+ <GlobalSearchBar />
32
+
33
+ <div className="flex-1" />
34
+
35
+ <Link
36
+ to="/entries/new"
37
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium bg-primary text-white hover:bg-primary-hover transition-colors"
38
+ >
39
+ <Plus size={14} />
40
+ New
41
+ </Link>
42
+
43
+ <button
44
+ onClick={toggleTheme}
45
+ className="p-1.5 rounded-md text-text-dim hover:text-text hover:bg-surface transition-colors"
46
+ title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
47
+ >
48
+ {theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
49
+ </button>
50
+ </header>
51
+
52
+ <main className="flex-1 overflow-auto">
53
+ {children}
54
+ </main>
55
+ </div>
56
+ );
57
+ }
@@ -0,0 +1,103 @@
1
+ import { useState, useCallback } from 'react';
2
+ import { Search, FileText, Filter } from 'lucide-react';
3
+ import type { SearchResult } from '../types';
4
+ import { api } from '../api/client';
5
+
6
+ interface Props {
7
+ entryId: string;
8
+ onSelectFile: (path: string) => void;
9
+ scopeLabel?: string;
10
+ }
11
+
12
+ export default function SearchBar({ entryId, onSelectFile, scopeLabel }: Props) {
13
+ const [query, setQuery] = useState('');
14
+ const [results, setResults] = useState<SearchResult[]>([]);
15
+ const [loading, setLoading] = useState(false);
16
+ const [open, setOpen] = useState(false);
17
+ const [debounceTimer, setDebounceTimer] = useState<ReturnType<typeof setTimeout>>();
18
+
19
+ const doSearch = useCallback(async (q: string) => {
20
+ if (!q.trim()) {
21
+ setResults([]);
22
+ setOpen(false);
23
+ return;
24
+ }
25
+ setLoading(true);
26
+ try {
27
+ const data = await api.search(entryId, q);
28
+ setResults(data);
29
+ setOpen(true);
30
+ } catch {
31
+ // ignore
32
+ } finally {
33
+ setLoading(false);
34
+ }
35
+ }, [entryId]);
36
+
37
+ const handleChange = (value: string) => {
38
+ setQuery(value);
39
+ if (debounceTimer) clearTimeout(debounceTimer);
40
+ const timer = setTimeout(() => doSearch(value), 300);
41
+ setDebounceTimer(timer);
42
+ };
43
+
44
+ return (
45
+ <div className="relative">
46
+ <div className="flex items-center gap-2">
47
+ {scopeLabel && (
48
+ <span className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium text-text-muted bg-bg-alt ring-1 ring-border shrink-0">
49
+ <Filter size={10} />
50
+ {scopeLabel}
51
+ </span>
52
+ )}
53
+ <div className="relative flex-1">
54
+ <Search size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-text-muted" />
55
+ <input
56
+ type="text"
57
+ value={query}
58
+ onChange={(e) => handleChange(e.target.value)}
59
+ onFocus={() => results.length > 0 && setOpen(true)}
60
+ placeholder="Search documentation..."
61
+ className="w-full pl-10 pr-3 py-2.5 bg-surface ring-1 ring-border rounded-lg text-sm text-text placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all"
62
+ />
63
+ {loading && (
64
+ <div className="absolute right-3 top-1/2 -translate-y-1/2">
65
+ <div className="w-4 h-4 border-2 border-border border-t-primary rounded-full animate-spin" />
66
+ </div>
67
+ )}
68
+ </div>
69
+ </div>
70
+
71
+ {open && results.length > 0 && (
72
+ <>
73
+ <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
74
+ <div className="absolute z-20 top-full mt-2 w-full bg-surface ring-1 ring-border rounded-xl shadow-lg max-h-80 overflow-auto">
75
+ {results.map((r, i) => (
76
+ <button
77
+ key={i}
78
+ type="button"
79
+ onClick={() => { onSelectFile(r.path); setOpen(false); }}
80
+ className="flex items-start gap-3 px-4 py-3 hover:bg-bg-alt transition-colors border-b border-border last:border-b-0 w-full text-left cursor-pointer"
81
+ >
82
+ <FileText size={16} className="text-text-muted mt-0.5 shrink-0" />
83
+ <div className="min-w-0 flex-1">
84
+ <div className="text-sm font-medium text-text truncate">{r.title}</div>
85
+ <div className="text-xs text-text-dim mt-0.5 line-clamp-2">{r.snippet}</div>
86
+ </div>
87
+ </button>
88
+ ))}
89
+ </div>
90
+ </>
91
+ )}
92
+
93
+ {open && query && !loading && results.length === 0 && (
94
+ <>
95
+ <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
96
+ <div className="absolute z-20 top-full mt-2 w-full bg-surface ring-1 ring-border rounded-xl shadow-lg p-4 text-center text-sm text-text-dim">
97
+ No results found for &ldquo;{query}&rdquo;
98
+ </div>
99
+ </>
100
+ )}
101
+ </div>
102
+ );
103
+ }