@glossarist/concept-browser 0.4.15 → 0.4.18

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.4.15",
3
+ "version": "0.4.18",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -108,8 +108,22 @@ describe('ConceptCard', () => {
108
108
  expect(router.currentRoute.value.params.conceptId).toBe('3.1.1.1');
109
109
  });
110
110
 
111
- it('shows language count from manifest', () => {
111
+ it('shows language count from designations', () => {
112
112
  const wrapper = mountCard();
113
- expect(wrapper.text()).toContain('2 lang');
113
+ expect(wrapper.text()).toContain('1 lang');
114
+ });
115
+
116
+ it('shows selected language designation as title', () => {
117
+ const wrapper = mountCard(makeEntry({
118
+ designations: { eng: 'test term', fra: 'terme test' },
119
+ eng: 'test term',
120
+ }), 'test');
121
+ expect(wrapper.text()).toContain('test term');
122
+
123
+ const wrapperFra = mount(ConceptCard, {
124
+ global: { plugins: [pinia, router] },
125
+ props: { entry: makeEntry({ designations: { eng: 'test term', fra: 'terme test' }, eng: 'test term' }), registerId: 'test', displayLang: 'fra' },
126
+ });
127
+ expect(wrapperFra.text()).toContain('terme test');
114
128
  });
115
129
  });
@@ -238,6 +238,7 @@ function conceptFromJsonLd(doc: Record<string, any>): Concept {
238
238
  }
239
239
 
240
240
  const related = (doc['gl:references'] ?? []).map(mapRelatedFromJsonLd);
241
+ const tags = Array.isArray(doc['gl:tags']) ? [...doc['gl:tags']] : [];
241
242
 
242
243
  return Concept.fromJSON({
243
244
  id,
@@ -245,6 +246,7 @@ function conceptFromJsonLd(doc: Record<string, any>): Concept {
245
246
  uri: doc['@id'] ?? null,
246
247
  localizations,
247
248
  related,
249
+ tags,
248
250
  status: null,
249
251
  });
250
252
  }
@@ -82,6 +82,7 @@ export interface ConceptEntry {
82
82
  id: string;
83
83
  designations: Record<string, string>;
84
84
  groups: string[];
85
+ tags: string[];
85
86
  status: string;
86
87
  }
87
88
 
@@ -8,6 +8,7 @@ import { useVocabularyStore } from '../stores/vocabulary';
8
8
  const props = defineProps<{
9
9
  entry: ConceptSummary;
10
10
  registerId: string;
11
+ displayLang?: string | null;
11
12
  }>();
12
13
 
13
14
  const router = useRouter();
@@ -29,6 +30,17 @@ function statusColor(status: string): string {
29
30
  }
30
31
 
31
32
  const manifestLanguages = computed(() => store.manifests.get(props.registerId)?.languages ?? []);
33
+
34
+ const displayTitle = computed(() => {
35
+ if (props.displayLang && props.entry.designations?.[props.displayLang]) {
36
+ return props.entry.designations[props.displayLang];
37
+ }
38
+ return props.entry.eng || props.entry.id;
39
+ });
40
+
41
+ const langCount = computed(() => {
42
+ return Object.keys(props.entry.designations ?? {}).length;
43
+ });
32
44
  </script>
33
45
 
34
46
  <template>
@@ -41,7 +53,7 @@ const manifestLanguages = computed(() => store.manifests.get(props.registerId)?.
41
53
  <div class="flex items-start justify-between gap-2">
42
54
  <div class="min-w-0">
43
55
  <h3 class="font-medium text-ink-800 truncate group-hover:text-ink-900 transition-colors leading-snug text-[15px]">
44
- {{ entry.eng || entry.id }}
56
+ {{ displayTitle }}
45
57
  </h3>
46
58
  <p class="text-[11px] text-ink-300 mt-1 font-mono tabular-nums">{{ entry.id }}</p>
47
59
  </div>
@@ -53,14 +65,14 @@ const manifestLanguages = computed(() => store.manifests.get(props.registerId)?.
53
65
  </span>
54
66
  </div>
55
67
  <!-- Language coverage -->
56
- <div class="flex items-center gap-1.5 mt-2.5" :aria-label="`${manifestLanguages.length} languages`">
57
- <span class="text-[11px] text-ink-300">{{ manifestLanguages.length }} lang</span>
68
+ <div class="flex items-center gap-1.5 mt-2.5">
69
+ <span class="text-[11px] text-ink-300">{{ langCount }} lang</span>
58
70
  <div class="flex gap-0.5">
59
71
  <span
60
72
  v-for="lang in manifestLanguages"
61
73
  :key="lang"
62
74
  class="w-1.5 h-1.5 rounded-full"
63
- :style="{ backgroundColor: getColor(registerId) + '40' }"
75
+ :style="{ backgroundColor: (lang in (entry.designations ?? {})) ? getColor(registerId) : getColor(registerId) + '20' }"
64
76
  :aria-label="lang"
65
77
  ></span>
66
78
  </div>
@@ -83,6 +83,9 @@ const conceptDates = computed(() => props.concept.dates);
83
83
  // Managed concept sources (distinct from localized sources)
84
84
  const conceptSources = computed(() => props.concept.sources);
85
85
 
86
+ // Managed concept tags
87
+ const conceptTags = computed(() => props.concept.tags ?? []);
88
+
86
89
  // Cross-reference resolver: generates clickable links for inline refs
87
90
 
88
91
  const { ensureBibLoaded, bibResolver, figResolver } = useRenderOptions(() => props.registerId);
@@ -683,6 +686,14 @@ const nonVerbalReps = computed(() => {
683
686
  </div>
684
687
  </div>
685
688
 
689
+ <!-- Tags -->
690
+ <div v-if="conceptTags.length" class="card p-5">
691
+ <div class="section-label">Tags</div>
692
+ <div class="flex flex-wrap gap-1.5 mt-3">
693
+ <span v-for="tag in conceptTags" :key="tag" class="badge badge-gray text-[10px]">{{ tag }}</span>
694
+ </div>
695
+ </div>
696
+
686
697
  <!-- Managed concept dates -->
687
698
  <div v-if="conceptDates.length" class="card p-5">
688
699
  <div class="section-label">Lifecycle dates</div>
@@ -4,6 +4,7 @@ import { useVocabularyStore } from '../stores/vocabulary';
4
4
  import { useDsStyle } from '../utils/dataset-style';
5
5
  import { useDatasetLoader } from '../composables/use-dataset-loader';
6
6
  import { FORMAT_LABELS } from '../config/types';
7
+ import { langName, langLabel, sortLanguages } from '../utils/lang';
7
8
  import ConceptCard from '../components/ConceptCard.vue';
8
9
 
9
10
  const props = defineProps<{ registerId: string }>();
@@ -38,6 +39,26 @@ const totalConceptCount = computed(() => adapter.value?.getConceptCount() ?? 0);
38
39
  const filter = ref('');
39
40
  const filterInput = ref<HTMLInputElement | null>(null);
40
41
  const allChunksLoaded = ref(false);
42
+ const selectedLang = ref<string | null>(null);
43
+
44
+ interface LangOption {
45
+ code: string;
46
+ name: string;
47
+ label: string;
48
+ termCount: number;
49
+ }
50
+
51
+ const languageOptions = computed<LangOption[]>(() => {
52
+ const m = manifest.value;
53
+ if (!m) return [];
54
+ const sorted = sortLanguages(m.languages, m.languageOrder);
55
+ return sorted.map(code => ({
56
+ code,
57
+ name: langName(code),
58
+ label: langLabel(code),
59
+ termCount: m.languageStats?.[code]?.terms ?? 0,
60
+ }));
61
+ });
41
62
 
42
63
  function onGlobalKeydown(e: KeyboardEvent) {
43
64
  if (e.key === '/' && document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'TEXTAREA') {
@@ -65,6 +86,17 @@ watch(filter, async (q) => {
65
86
  }
66
87
  });
67
88
 
89
+ // When language filter changes, reset page and load all chunks
90
+ watch(selectedLang, async (lang) => {
91
+ page.value = 1;
92
+ if (lang && !allChunksLoaded.value && adapter.value) {
93
+ chunkLoading.value = true;
94
+ await adapter.value.ensureAllChunksLoaded();
95
+ allChunksLoaded.value = true;
96
+ chunkLoading.value = false;
97
+ }
98
+ });
99
+
68
100
  // Dense array: only loaded (non-undefined) entries
69
101
  const loadedConcepts = computed(() => {
70
102
  const arr = adapter.value?.getConcepts();
@@ -74,8 +106,10 @@ const loadedConcepts = computed(() => {
74
106
 
75
107
  const filtered = computed(() => {
76
108
  const q = filter.value.trim().toLowerCase();
77
- if (!q) return loadedConcepts.value;
109
+ const lang = selectedLang.value;
78
110
  return loadedConcepts.value.filter(c => {
111
+ if (lang && !(lang in (c.designations ?? {}))) return false;
112
+ if (!q) return true;
79
113
  return (c.eng || '').toLowerCase().includes(q) || c.id.toLowerCase().includes(q);
80
114
  });
81
115
  });
@@ -91,8 +125,8 @@ const pageLoaded = computed(() => {
91
125
  });
92
126
 
93
127
  const paged = computed(() => {
94
- // When filtering, paginate over filtered dense results (all chunks loaded)
95
- if (filter.value.trim()) {
128
+ // When filtering (text or language), paginate over filtered dense results (all chunks loaded)
129
+ if (filter.value.trim() || selectedLang.value) {
96
130
  const start = (page.value - 1) * perPage;
97
131
  return filtered.value.slice(start, start + perPage);
98
132
  }
@@ -104,7 +138,7 @@ const paged = computed(() => {
104
138
  });
105
139
 
106
140
  const totalPages = computed(() => {
107
- if (filter.value.trim()) {
141
+ if (filter.value.trim() || selectedLang.value) {
108
142
  return Math.max(1, Math.ceil(filtered.value.length / perPage));
109
143
  }
110
144
  return Math.max(1, Math.ceil(totalConceptCount.value / perPage));
@@ -112,7 +146,7 @@ const totalPages = computed(() => {
112
146
 
113
147
  // Load chunks needed for current page
114
148
  watch(page, async () => {
115
- if (!adapter.value || filter.value.trim()) return;
149
+ if (!adapter.value || filter.value.trim() || selectedLang.value) return;
116
150
  const start = (page.value - 1) * perPage;
117
151
  if (!adapter.value.isRangeLoaded(start, perPage)) {
118
152
  chunkLoading.value = true;
@@ -229,7 +263,10 @@ function goToPage(p: number) {
229
263
  </svg>
230
264
  </div>
231
265
  <span class="text-sm text-ink-400">
232
- <template v-if="filter.trim()">
266
+ <template v-if="selectedLang">
267
+ {{ filtered.length.toLocaleString() }} of {{ totalConceptCount.toLocaleString() }} concepts in {{ langName(selectedLang) }}
268
+ </template>
269
+ <template v-else-if="filter.trim()">
233
270
  {{ filtered.length.toLocaleString() }} of {{ totalConceptCount.toLocaleString() }} concepts
234
271
  </template>
235
272
  <template v-else-if="totalPages > 1">
@@ -241,6 +278,39 @@ function goToPage(p: number) {
241
278
  </span>
242
279
  </div>
243
280
 
281
+ <!-- Language filter -->
282
+ <div v-if="languageOptions.length > 1" class="flex flex-wrap gap-1.5 mb-5">
283
+ <button
284
+ @click="selectedLang = null"
285
+ :class="[
286
+ selectedLang === null
287
+ ? 'bg-ink-800 text-white'
288
+ : 'bg-surface-raised text-ink-600 hover:bg-ink-50 border border-ink-100'
289
+ ]"
290
+ class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
291
+ >
292
+ All {{ totalConceptCount.toLocaleString() }}
293
+ </button>
294
+ <button
295
+ v-for="lang in languageOptions"
296
+ :key="lang.code"
297
+ @click="selectedLang = selectedLang === lang.code ? null : lang.code"
298
+ :class="[
299
+ selectedLang === lang.code
300
+ ? 'bg-ink-800 text-white'
301
+ : 'bg-surface-raised text-ink-600 hover:bg-ink-50 border border-ink-100'
302
+ ]"
303
+ class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5"
304
+ >
305
+ <span
306
+ class="text-[10px] font-semibold px-1.5 py-0.5 rounded"
307
+ :class="selectedLang === lang.code ? 'bg-ink-700 text-ink-200' : 'bg-ink-50 text-ink-500'"
308
+ >{{ lang.label }}</span>
309
+ {{ lang.name }}
310
+ <span class="text-[10px] opacity-60">{{ lang.termCount }}</span>
311
+ </button>
312
+ </div>
313
+
244
314
  <!-- Chunk loading skeleton -->
245
315
  <div v-if="chunkLoading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
246
316
  <div v-for="i in 6" :key="i" class="skeleton h-20"></div>
@@ -253,6 +323,7 @@ function goToPage(p: number) {
253
323
  :key="entry.id"
254
324
  :entry="entry"
255
325
  :register-id="registerId"
326
+ :display-lang="selectedLang"
256
327
  class="animate-entrance"
257
328
  :style="{ animationDelay: `${Math.min(idx, 20) * 30}ms` }"
258
329
  />