@glossarist/concept-browser 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +319 -0
  2. package/cli/index.mjs +119 -0
  3. package/env.d.ts +7 -0
  4. package/index.html +16 -0
  5. package/package.json +78 -0
  6. package/postcss.config.js +6 -0
  7. package/scripts/build-edges.js +112 -0
  8. package/scripts/fetch-datasets.mjs +195 -0
  9. package/scripts/generate-404.js +15 -0
  10. package/scripts/generate-data.mjs +606 -0
  11. package/scripts/load-site-config.mjs +56 -0
  12. package/src/App.vue +98 -0
  13. package/src/__tests__/data-integration.test.ts +135 -0
  14. package/src/__tests__/data-integrity.test.ts +101 -0
  15. package/src/__tests__/dataset-adapter.test.ts +336 -0
  16. package/src/__tests__/dataset-style.test.ts +37 -0
  17. package/src/__tests__/graph.test.ts +187 -0
  18. package/src/__tests__/lang.test.ts +29 -0
  19. package/src/__tests__/math.test.ts +113 -0
  20. package/src/__tests__/reference-resolver.test.ts +122 -0
  21. package/src/__tests__/site-config.test.ts +52 -0
  22. package/src/__tests__/uri-router.test.ts +76 -0
  23. package/src/adapters/DatasetAdapter.ts +270 -0
  24. package/src/adapters/ReferenceResolver.ts +95 -0
  25. package/src/adapters/UriRouter.ts +41 -0
  26. package/src/adapters/factory.ts +78 -0
  27. package/src/adapters/types.ts +162 -0
  28. package/src/components/AppHeader.vue +99 -0
  29. package/src/components/AppSidebar.vue +133 -0
  30. package/src/components/ConceptCard.vue +65 -0
  31. package/src/components/ConceptDetail.vue +540 -0
  32. package/src/components/ConceptTimeline.vue +410 -0
  33. package/src/components/FormatDownloads.vue +46 -0
  34. package/src/components/GraphPanel.vue +499 -0
  35. package/src/components/LanguageDetail.vue +211 -0
  36. package/src/components/NavIcon.vue +20 -0
  37. package/src/components/SearchBar.vue +241 -0
  38. package/src/composables/use-dataset-loader.ts +27 -0
  39. package/src/config/types.ts +130 -0
  40. package/src/config/use-site-config.ts +144 -0
  41. package/src/graph/GraphEngine.ts +137 -0
  42. package/src/graph/index.ts +1 -0
  43. package/src/main.ts +11 -0
  44. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  45. package/src/router/index.ts +43 -0
  46. package/src/router/page-routes.ts +35 -0
  47. package/src/stores/ui.ts +59 -0
  48. package/src/stores/vocabulary.ts +309 -0
  49. package/src/style.css +314 -0
  50. package/src/utils/asciidoc-lite.ts +123 -0
  51. package/src/utils/concept-formats.ts +157 -0
  52. package/src/utils/dataset-style.ts +54 -0
  53. package/src/utils/index.ts +1 -0
  54. package/src/utils/lang.ts +32 -0
  55. package/src/utils/math.ts +100 -0
  56. package/src/views/AboutView.vue +122 -0
  57. package/src/views/ConceptView.vue +119 -0
  58. package/src/views/ContributorsView.vue +110 -0
  59. package/src/views/DatasetView.vue +249 -0
  60. package/src/views/GraphView.vue +65 -0
  61. package/src/views/HomeView.vue +168 -0
  62. package/src/views/NewsView.vue +146 -0
  63. package/src/views/ResolveView.vue +63 -0
  64. package/src/views/SearchView.vue +33 -0
  65. package/src/views/StatsView.vue +121 -0
  66. package/tailwind.config.js +43 -0
  67. package/tsconfig.json +24 -0
  68. package/vite.config.ts +27 -0
@@ -0,0 +1,211 @@
1
+ <script setup lang="ts">
2
+ import type { LocalizedConcept, Designation, ConceptSource } from '../adapters/types';
3
+ import { computed } from 'vue';
4
+ import { langName, langLabel } from '../utils/lang';
5
+ import { renderMath } from '../utils/math';
6
+ import type { XrefResolver } from '../utils/math';
7
+ import { useRouter } from 'vue-router';
8
+ import { useVocabularyStore } from '../stores/vocabulary';
9
+ import { getFactory } from '../adapters/factory';
10
+
11
+ const props = defineProps<{
12
+ localizedConcepts: Record<string, LocalizedConcept>;
13
+ activeLang: string;
14
+ }>();
15
+
16
+ const emit = defineEmits<{
17
+ (e: 'update:activeLang', lang: string): void;
18
+ }>();
19
+
20
+ const lc = computed(() => props.localizedConcepts[props.activeLang]);
21
+ const availableLangs = computed(() => Object.keys(props.localizedConcepts).sort());
22
+
23
+ const designations = computed(() => lc.value?.['gl:designation'] ?? []);
24
+ const definition = computed(() => {
25
+ const defs = lc.value?.['gl:definition'];
26
+ if (defs?.length) {
27
+ const content = defs.map(d => d['gl:content']).filter(Boolean).join('\n\n');
28
+ if (content) return content;
29
+ }
30
+ return '';
31
+ });
32
+ const notes = computed(() => {
33
+ return lc.value?.['gl:notes']?.map(n => n['gl:content']).filter(Boolean) ?? [];
34
+ });
35
+ const examples = computed(() => lc.value?.['gl:examples']?.map(e => e['gl:content']).filter(Boolean) ?? []);
36
+ const sources = computed(() => lc.value?.['gl:source'] ?? []);
37
+
38
+ const hasContent = computed(() =>
39
+ definition.value || notes.value.length > 0 || examples.value.length > 0 || designations.value.length > 1
40
+ );
41
+
42
+ const isTermOnly = computed(() =>
43
+ !definition.value && notes.value.length === 0 && examples.value.length === 0
44
+ );
45
+
46
+ function normativeStatus(status: string): string {
47
+ return status === 'preferred' ? 'Preferred' : status;
48
+ }
49
+ function normativeColor(status: string): string {
50
+ if (status === 'preferred') return 'bg-emerald-50 text-emerald-700';
51
+ if (status === 'deprecated') return 'bg-red-50 text-red-700';
52
+ return 'bg-amber-50 text-amber-700';
53
+ }
54
+ function entryStatusColor(status: string): string {
55
+ if (status === 'valid' || status === 'Standard') return 'badge-green';
56
+ if (status === 'superseded') return 'bg-red-50 text-red-700';
57
+ if (status === 'withdrawn') return 'bg-red-100 text-red-800';
58
+ if (status === 'draft') return 'badge-yellow';
59
+ return 'badge-gray';
60
+ }
61
+ function designationTypeLabel(type: string): string {
62
+ const labels: Record<string, string> = {
63
+ 'gl:Expression': 'Expression',
64
+ 'gl:Symbol': 'Symbol',
65
+ 'gl:Abbreviation': 'Abbreviation',
66
+ 'gl:GraphicalSymbol': 'Graphical',
67
+ };
68
+ return labels[type] ?? type;
69
+ }
70
+ function designationTypeColor(type: string): string {
71
+ if (type === 'gl:Symbol') return 'badge-purple';
72
+ if (type === 'gl:Abbreviation') return 'badge-yellow';
73
+ return 'badge-blue';
74
+ }
75
+
76
+ const router = useRouter();
77
+ const store = useVocabularyStore();
78
+
79
+ function escapeAttr(s: string) {
80
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
81
+ }
82
+
83
+ const factory = getFactory();
84
+
85
+ const xrefResolver: XrefResolver = (uri, term) => {
86
+ return `<a href="#" class="xref-link" data-uri="${escapeAttr(uri)}">${escapeAttr(term)}</a>`;
87
+ };
88
+
89
+ function handleContentClick(e: MouseEvent) {
90
+ const target = (e.target as HTMLElement).closest('.xref-link') as HTMLElement | null;
91
+ if (!target) return;
92
+ e.preventDefault();
93
+ const uri = target.dataset.uri;
94
+ if (uri) {
95
+ const resolution = factory.resolve(uri);
96
+ if (resolution.type === 'internal') {
97
+ store.viewConcept(resolution.registerId, resolution.conceptId);
98
+ router.push({ name: 'concept', params: { registerId: resolution.registerId, conceptId: resolution.conceptId } });
99
+ }
100
+ }
101
+ }
102
+ </script>
103
+
104
+ <template>
105
+ <div class="space-y-5" @click="handleContentClick">
106
+ <!-- Language selector -->
107
+ <div class="flex flex-wrap gap-1.5">
108
+ <button
109
+ v-for="lang in availableLangs"
110
+ :key="lang"
111
+ @click="emit('update:activeLang', lang)"
112
+ :class="[
113
+ activeLang === lang
114
+ ? 'bg-ink-800 text-white'
115
+ : 'bg-surface-raised text-ink-600 hover:bg-ink-50 border border-ink-100'
116
+ ]"
117
+ class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5"
118
+ >
119
+ <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langLabel(lang) }}</span>
120
+ {{ langName(lang) }}
121
+ </button>
122
+ </div>
123
+
124
+ <!-- Content for selected language -->
125
+ <div v-if="lc">
126
+ <!-- Entry status -->
127
+ <div v-if="lc['gl:entryStatus']" class="flex items-center gap-2 mb-4">
128
+ <span class="badge" :class="entryStatusColor(lc['gl:entryStatus'])">{{ lc['gl:entryStatus'] }}</span>
129
+ </div>
130
+
131
+ <!-- Designations -->
132
+ <div v-if="designations.length > 0" class="card p-5">
133
+ <div class="section-label">Designations</div>
134
+ <div class="space-y-2 mt-3">
135
+ <div v-for="(d, i) in designations" :key="i" class="flex items-center gap-2 flex-wrap">
136
+ <span class="font-medium text-ink-800 text-lg" v-html="renderMath(d['gl:term'])"></span>
137
+ <span class="badge text-[10px]" :class="designationTypeColor(d['@type'])">{{ designationTypeLabel(d['@type']) }}</span>
138
+ <span class="badge text-[10px]" :class="normativeColor(d['gl:normativeStatus'])">{{ normativeStatus(d['gl:normativeStatus']) }}</span>
139
+ <span v-if="d['gl:gender']" class="text-xs text-ink-300">{{ d['gl:gender'] }}</span>
140
+ <span v-if="d['gl:plurality']" class="text-xs text-ink-300">{{ d['gl:plurality'] }}</span>
141
+ </div>
142
+ </div>
143
+ </div>
144
+
145
+ <!-- Definition -->
146
+ <div v-if="definition" class="card p-5">
147
+ <div class="section-label">Definition</div>
148
+ <div class="text-ink-800 leading-relaxed mt-3" v-html="renderMath(definition, xrefResolver)"></div>
149
+ </div>
150
+
151
+ <!-- Notes -->
152
+ <div v-if="notes.length" class="card p-5">
153
+ <div class="section-label">Notes</div>
154
+ <div class="space-y-3 mt-3">
155
+ <div v-for="(note, i) in notes" :key="i" class="text-ink-600 text-sm leading-relaxed">
156
+ <span class="font-medium text-ink-400 text-xs uppercase tracking-wide">Note {{ i + 1 }}</span>
157
+ <div class="mt-1" v-html="renderMath(note, xrefResolver)"></div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+
162
+ <!-- Examples -->
163
+ <div v-if="examples.length" class="card p-5">
164
+ <div class="section-label">Examples</div>
165
+ <div class="space-y-3 mt-3">
166
+ <div v-for="(ex, i) in examples" :key="i" class="text-ink-600 text-sm leading-relaxed">
167
+ <span class="font-medium text-ink-400 text-xs uppercase tracking-wide">Example {{ i + 1 }}</span>
168
+ <div class="mt-1" v-html="renderMath(ex, xrefResolver)"></div>
169
+ </div>
170
+ </div>
171
+ </div>
172
+
173
+ <!-- Sources -->
174
+ <div v-if="sources.length" class="card p-5">
175
+ <div class="section-label">Sources</div>
176
+ <div class="space-y-3 mt-3">
177
+ <div v-for="(src, i) in sources" :key="i" class="text-sm">
178
+ <div class="flex items-center gap-1.5 flex-wrap mb-1">
179
+ <span v-if="src['gl:sourceType']" class="badge badge-blue text-[10px]">{{ src['gl:sourceType'] }}</span>
180
+ <span v-if="src['gl:sourceStatus']" class="badge badge-gray text-[10px]">{{ src['gl:sourceStatus'] }}</span>
181
+ </div>
182
+ <div class="text-ink-700">
183
+ <span v-if="src['gl:origin']?.['gl:ref']" class="font-medium">{{ src['gl:origin']['gl:ref'] }}</span>
184
+ <span v-if="src['gl:origin']?.['gl:clause']">, {{ src['gl:origin']['gl:clause'] }}</span>
185
+ </div>
186
+ </div>
187
+ </div>
188
+ </div>
189
+
190
+ <!-- Term-only state -->
191
+ <div v-if="isTermOnly" class="card p-5">
192
+ <div class="flex items-start gap-3">
193
+ <div class="w-8 h-8 rounded-full bg-ink-50 flex items-center justify-center flex-shrink-0 mt-0.5">
194
+ <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langLabel(activeLang) }}</span>
195
+ </div>
196
+ <div>
197
+ <p class="text-sm text-ink-600 font-medium">Term only in {{ langName(activeLang) }}</p>
198
+ <p class="text-xs text-ink-400 mt-1 leading-relaxed">
199
+ This concept has a registered designation in {{ langName(activeLang) }} but no definition or notes.
200
+ </p>
201
+ </div>
202
+ </div>
203
+ </div>
204
+ </div>
205
+
206
+ <!-- No data for this language -->
207
+ <div v-else class="card p-5 text-center">
208
+ <p class="text-sm text-ink-400">No data available for {{ langName(activeLang) }}.</p>
209
+ </div>
210
+ </div>
211
+ </template>
@@ -0,0 +1,20 @@
1
+ <script setup lang="ts">
2
+ const icons: Record<string, string> = {
3
+ home: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1',
4
+ search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
5
+ graph: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
6
+ newspaper: 'M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z',
7
+ users: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z',
8
+ info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
9
+ chart: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
10
+ list: 'M4 6h16M4 10h16M4 14h16M4 18h16',
11
+ };
12
+
13
+ defineProps<{ name: string }>();
14
+ </script>
15
+
16
+ <template>
17
+ <svg class="w-4 h-4 text-ink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
18
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" :d="icons[name] || icons.info" />
19
+ </svg>
20
+ </template>
@@ -0,0 +1,241 @@
1
+ <script setup lang="ts">
2
+ import { useRouter } from 'vue-router';
3
+ import { useUiStore } from '../stores/ui';
4
+ import { useVocabularyStore } from '../stores/vocabulary';
5
+ import { ref, watch, onMounted, computed, nextTick } from 'vue';
6
+ import type { SearchHit } from '../adapters/types';
7
+ import { langLabel, langName } from '../utils/lang';
8
+ import { useDsStyle } from '../utils/dataset-style';
9
+
10
+ const router = useRouter();
11
+ const ui = useUiStore();
12
+ const store = useVocabularyStore();
13
+ const { getStyle } = useDsStyle();
14
+ const query = ref('');
15
+ const results = ref<SearchHit[]>([]);
16
+ const searched = ref(false);
17
+ const selectedIdx = ref(-1);
18
+ const loading = ref(false);
19
+ const searchError = ref<string | null>(null);
20
+ const searchInputEl = ref<HTMLInputElement | null>(null);
21
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
22
+
23
+ async function doSearch() {
24
+ const q = query.value.trim();
25
+ if (!q) return;
26
+ ui.searchQuery = q;
27
+ loading.value = true;
28
+ searchError.value = null;
29
+ try {
30
+ results.value = await store.searchAcrossDatasets(q);
31
+ } catch (e: any) {
32
+ searchError.value = e.message || 'Search failed';
33
+ } finally {
34
+ loading.value = false;
35
+ }
36
+ searched.value = true;
37
+ selectedIdx.value = -1;
38
+ router.replace({ query: { q } });
39
+ }
40
+
41
+ function onInput() {
42
+ if (debounceTimer) clearTimeout(debounceTimer);
43
+ debounceTimer = setTimeout(async () => {
44
+ const q = query.value.trim();
45
+ if (q.length >= 2) {
46
+ await doSearch();
47
+ } else if (q.length === 0) {
48
+ results.value = [];
49
+ searched.value = false;
50
+ selectedIdx.value = -1;
51
+ }
52
+ }, 300);
53
+ }
54
+
55
+ function clearSearch() {
56
+ query.value = '';
57
+ results.value = [];
58
+ searched.value = false;
59
+ selectedIdx.value = -1;
60
+ router.replace({ query: {} });
61
+ }
62
+
63
+ // Group results by dataset
64
+ interface GroupedResults {
65
+ registerId: string;
66
+ title: string;
67
+ style: { light: string; dark: string; color: string };
68
+ hits: SearchHit[];
69
+ }
70
+
71
+ const groupedResults = computed(() => {
72
+ const capped = results.value.slice(0, 100);
73
+ const map = new Map<string, SearchHit[]>();
74
+ for (const hit of capped) {
75
+ const group = map.get(hit.registerId) ?? [];
76
+ group.push(hit);
77
+ map.set(hit.registerId, group);
78
+ }
79
+ const groups: GroupedResults[] = [];
80
+ for (const [registerId, hits] of map) {
81
+ const m = store.manifests.get(registerId);
82
+ groups.push({
83
+ registerId,
84
+ title: m?.title ?? registerId,
85
+ style: getStyle(registerId),
86
+ hits,
87
+ });
88
+ }
89
+ return groups;
90
+ });
91
+
92
+ // Flatten for keyboard navigation
93
+ const flatHits = computed(() => groupedResults.value.flatMap(g => g.hits));
94
+ const hitIndexMap = computed(() => {
95
+ const map = new Map<SearchHit, number>();
96
+ flatHits.value.forEach((hit, i) => map.set(hit, i));
97
+ return map;
98
+ });
99
+
100
+ function goToHit(hit: SearchHit) {
101
+ router.push({
102
+ name: 'concept',
103
+ params: { registerId: hit.registerId, conceptId: hit.conceptId },
104
+ });
105
+ }
106
+
107
+ function onKeydown(e: KeyboardEvent) {
108
+ if (!searched.value || flatHits.value.length === 0) return;
109
+
110
+ if (e.key === 'ArrowDown') {
111
+ e.preventDefault();
112
+ selectedIdx.value = Math.min(selectedIdx.value + 1, flatHits.value.length - 1);
113
+ scrollToSelected();
114
+ } else if (e.key === 'ArrowUp') {
115
+ e.preventDefault();
116
+ selectedIdx.value = Math.max(selectedIdx.value - 1, -1);
117
+ scrollToSelected();
118
+ } else if (e.key === 'Enter' && selectedIdx.value >= 0) {
119
+ e.preventDefault();
120
+ goToHit(flatHits.value[selectedIdx.value]);
121
+ }
122
+ }
123
+
124
+ function scrollToSelected() {
125
+ nextTick(() => {
126
+ document.querySelector<HTMLElement>('.search-hit-selected')?.scrollIntoView({ block: 'nearest' });
127
+ });
128
+ }
129
+
130
+ // Sync with UI store search query
131
+ watch(() => ui.searchQuery, (q) => {
132
+ if (q && q !== query.value) {
133
+ query.value = q;
134
+ doSearch();
135
+ }
136
+ });
137
+
138
+ onMounted(() => {
139
+ if (ui.searchQuery) {
140
+ query.value = ui.searchQuery;
141
+ doSearch();
142
+ }
143
+ });
144
+ </script>
145
+
146
+ <template>
147
+ <div class="max-w-2xl mx-auto px-0">
148
+ <form @submit.prevent="doSearch" class="mb-6 sm:mb-8">
149
+ <div class="flex gap-2">
150
+ <div class="relative flex-1">
151
+ <input
152
+ ref="searchInputEl"
153
+ v-model="query"
154
+ @input="onInput"
155
+ @keydown="onKeydown"
156
+ type="text"
157
+ aria-label="Search terms across all datasets"
158
+ placeholder="Search terms across all datasets..."
159
+ class="w-full pl-9 pr-8 py-2.5 text-sm bg-surface border border-ink-100 rounded-lg focus:ring-2 focus:ring-ink-200 focus:border-ink-400 outline-none placeholder:text-ink-300 transition-all"
160
+ autofocus
161
+ />
162
+ <svg v-if="!loading" class="absolute left-3 top-3 w-4 h-4 text-ink-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
163
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
164
+ </svg>
165
+ <svg v-else class="absolute left-3 top-3 w-4 h-4 text-ink-400 animate-spin" fill="none" viewBox="0 0 24 24">
166
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
167
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
168
+ </svg>
169
+ <button
170
+ v-if="query"
171
+ @click="clearSearch"
172
+ type="button"
173
+ class="absolute right-2.5 top-2.5 text-ink-300 hover:text-ink-600 transition-colors"
174
+ >
175
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
176
+ </button>
177
+ </div>
178
+ <button type="submit" class="btn-primary" :disabled="loading">Search</button>
179
+ </div>
180
+ </form>
181
+
182
+ <div v-if="loading" class="text-center py-16">
183
+ <svg class="w-8 h-8 text-ink-300 animate-spin mx-auto mb-4" fill="none" viewBox="0 0 24 24">
184
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
185
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
186
+ </svg>
187
+ <p class="text-sm text-ink-400">Searching across datasets...</p>
188
+ </div>
189
+
190
+ <div v-else-if="searchError" class="text-center py-16">
191
+ <div class="card p-8 border-red-200 bg-red-50/50 max-w-md mx-auto">
192
+ <p class="text-red-700 font-medium mb-1">Search failed</p>
193
+ <p class="text-sm text-red-600/80 mb-4">{{ searchError }}</p>
194
+ <button @click="doSearch" class="btn-primary">Retry</button>
195
+ </div>
196
+ </div>
197
+
198
+ <div v-else-if="searched">
199
+ <p class="text-sm text-ink-400 mb-4">{{ results.length }} result{{ results.length === 1 ? '' : 's' }} for &ldquo;{{ ui.searchQuery }}&rdquo;</p>
200
+ <div v-if="results.length === 0" class="text-center py-16">
201
+ <div class="text-ink-200 text-5xl mb-4 font-serif">&empty;</div>
202
+ <p class="text-ink-500 font-medium">No concepts found matching your search</p>
203
+ <p class="text-sm text-ink-300 mt-1">Try a different term or check the spelling.</p>
204
+ </div>
205
+
206
+ <!-- Grouped results -->
207
+ <div v-else class="space-y-6">
208
+ <div v-for="group in groupedResults" :key="group.registerId">
209
+ <!-- Dataset header -->
210
+ <div class="flex items-center gap-2 mb-2">
211
+ <span class="w-2 h-2 rounded-full flex-shrink-0" :style="{ backgroundColor: group.style.color }"></span>
212
+ <span class="text-xs font-semibold text-ink-500 uppercase tracking-wide">{{ group.title }}</span>
213
+ <span class="text-xs text-ink-300">{{ group.hits.length }} result{{ group.hits.length === 1 ? '' : 's' }}</span>
214
+ </div>
215
+ <!-- Hits -->
216
+ <div class="space-y-1.5">
217
+ <button
218
+ v-for="hit in group.hits"
219
+ :key="hit.conceptId + hit.language"
220
+ @click="goToHit(hit)"
221
+ :class="selectedIdx === hitIndexMap.get(hit) ? 'bg-ink-50 border-ink-200 search-hit-selected' : ''"
222
+ class="card-hover p-3 w-full text-left flex items-center justify-between group"
223
+ >
224
+ <div class="min-w-0">
225
+ <span class="font-medium text-ink-800 group-hover:text-ink-900 transition-colors">{{ hit.designation }}</span>
226
+ <span class="text-xs text-ink-300 ml-2 font-mono">{{ hit.conceptId }}</span>
227
+ </div>
228
+ <div class="flex items-center gap-2 flex-shrink-0">
229
+ <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(hit.language) }}</span>
230
+ </div>
231
+ </button>
232
+ </div>
233
+ </div>
234
+ </div>
235
+
236
+ <div v-if="results.length > 100" class="text-center text-sm text-ink-300 mt-6 pt-4 border-t border-ink-100/60">
237
+ Showing first 100 of {{ results.length }} results. Refine your search for more specific matches.
238
+ </div>
239
+ </div>
240
+ </div>
241
+ </template>
@@ -0,0 +1,27 @@
1
+ import { ref, onMounted, watch } from 'vue';
2
+ import { useVocabularyStore } from '../stores/vocabulary';
3
+
4
+ export function useDatasetLoader(registerId: () => string) {
5
+ const store = useVocabularyStore();
6
+ const loading = ref(false);
7
+ const localError = ref<string | null>(null);
8
+
9
+ async function ensureLoaded() {
10
+ const id = registerId();
11
+ const adapter = store.datasets.get(id);
12
+ if (adapter?.index) return;
13
+ loading.value = true;
14
+ localError.value = null;
15
+ try {
16
+ await store.loadDataset(id);
17
+ } catch (e: any) {
18
+ localError.value = e.message || 'Failed to load dataset';
19
+ }
20
+ loading.value = false;
21
+ }
22
+
23
+ onMounted(ensureLoaded);
24
+ watch(registerId, ensureLoaded);
25
+
26
+ return { loading, localError, ensureLoaded };
27
+ }
@@ -0,0 +1,130 @@
1
+ // === Branding ===
2
+
3
+ export interface FontConfig {
4
+ family: string;
5
+ source: 'google' | 'url' | 'local';
6
+ weights?: number[];
7
+ url?: string;
8
+ }
9
+
10
+ export interface LogoConfig {
11
+ path: string;
12
+ alt: string;
13
+ url?: string;
14
+ remoteUrl?: string;
15
+ }
16
+
17
+ export interface SiteBranding {
18
+ primaryColor?: string;
19
+ darkColor?: string;
20
+ fonts?: {
21
+ header?: FontConfig;
22
+ body?: FontConfig;
23
+ };
24
+ logo?: LogoConfig;
25
+ footerLogo?: LogoConfig;
26
+ favicon?: string;
27
+ ownerName?: string;
28
+ ownerUrl?: string;
29
+ }
30
+
31
+ // === Features ===
32
+
33
+ export interface PoweredByConfig {
34
+ title: string;
35
+ url: string;
36
+ }
37
+
38
+ export interface SiteFeatures {
39
+ news?: boolean;
40
+ stats?: boolean;
41
+ graph?: boolean;
42
+ about?: boolean;
43
+ search?: boolean;
44
+ poweredBy?: PoweredByConfig;
45
+ }
46
+
47
+ // === Analytics ===
48
+
49
+ export interface AnalyticsConfig {
50
+ googleAnalyticsId?: string;
51
+ }
52
+
53
+ // === Navigation ===
54
+
55
+ export interface NavItem {
56
+ label: string;
57
+ route: string;
58
+ }
59
+
60
+ export interface SocialLinks {
61
+ github?: string;
62
+ twitter?: string;
63
+ [key: string]: string | undefined;
64
+ }
65
+
66
+ // === Routing ===
67
+
68
+ export type RoutingType = 'site' | 'url';
69
+
70
+ export interface RoutingEntry {
71
+ uri: string;
72
+ type: RoutingType;
73
+ targetDataset?: string;
74
+ baseUrl?: string;
75
+ url?: string;
76
+ label: string;
77
+ }
78
+
79
+ // === Dataset ===
80
+
81
+ export interface DatasetConfig {
82
+ id: string;
83
+ uri: string;
84
+ uriAliases?: string[];
85
+ gcrPackage: string;
86
+ sourceRepo?: string;
87
+ title: string;
88
+ description?: string;
89
+ owner?: string;
90
+ color?: string;
91
+ tags?: string[];
92
+ languageOrder?: string[];
93
+ }
94
+
95
+ // === Pages ===
96
+
97
+ export type PageType = 'news' | 'contributors' | 'about' | 'stats' | 'custom';
98
+
99
+ export interface PageConfig {
100
+ type: PageType;
101
+ route: string;
102
+ title: string;
103
+ icon: string;
104
+ source?: string;
105
+ datasetScoped?: boolean;
106
+ }
107
+
108
+ // === Site Config ===
109
+
110
+ export interface SiteConfig {
111
+ id: string;
112
+ domain: string;
113
+ title: string;
114
+ subtitle?: string;
115
+ description?: string;
116
+ datasets: DatasetConfig[];
117
+ routing: RoutingEntry[];
118
+ branding: SiteBranding;
119
+ analytics?: AnalyticsConfig;
120
+ features?: SiteFeatures;
121
+ social?: SocialLinks;
122
+ nav?: NavItem[];
123
+ footerNav?: NavItem[];
124
+ defaults: {
125
+ language?: string;
126
+ languageOrder?: string[];
127
+ };
128
+ email?: string;
129
+ pages?: PageConfig[];
130
+ }