@glossarist/concept-browser 0.7.11 → 0.7.13

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/README.md CHANGED
@@ -231,6 +231,39 @@ pages:
231
231
  source: about-fra.md
232
232
  ```
233
233
 
234
+ ### Dataset groups
235
+
236
+ When a site has many datasets, you can group them in the sidebar navigation. Datasets within a group are displayed under a collapsible header.
237
+
238
+ ```yaml
239
+ datasetGroups:
240
+ - id: viml
241
+ label: "VIML — International Vocabulary of Legal Metrology"
242
+ color: "#004996"
243
+ datasets: [viml-2022, viml-2013, viml-2000, viml-1968]
244
+ translations:
245
+ fra:
246
+ label: "VIML — Vocabulaire international de métrologie légale"
247
+
248
+ - id: vim
249
+ label: "VIM — International Vocabulary of Metrology"
250
+ color: "#005A9C"
251
+ datasets: [vim-2012, vim-2010, vim-2007, vim-1993]
252
+ ```
253
+
254
+ #### Dataset group field reference
255
+
256
+ | Field | Required | Description |
257
+ |-------|----------|-------------|
258
+ | `id` | yes | Unique identifier for the group |
259
+ | `label` | yes | Display name shown as the collapsible group header |
260
+ | `description` | no | Short description of the group |
261
+ | `color` | no | Hex color for the group header text |
262
+ | `datasets` | yes | Ordered array of dataset IDs belonging to this group |
263
+ | `translations` | no | Localized label and description per language |
264
+
265
+ Datasets not assigned to any group appear at the bottom of the dataset list. If `datasetGroups` is omitted, all datasets are displayed as a flat list (the default behavior).
266
+
234
267
  ### Cross-reference mapping
235
268
 
236
269
  ```yaml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.11",
3
+ "version": "0.7.13",
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": {
@@ -1170,6 +1170,7 @@ writeJson(path.join(PUBLIC, 'site-config.json'), {
1170
1170
  description: config.description,
1171
1171
  translations: config.translations || undefined,
1172
1172
  datasets: config.datasets.map(d => d.id),
1173
+ datasetGroups: config.datasetGroups || undefined,
1173
1174
  datasetTranslations: Object.keys(datasetTranslations).length ? datasetTranslations : undefined,
1174
1175
  defaultDataset: config.datasets.length === 1 ? config.datasets[0].id : undefined,
1175
1176
  uiLanguages: config.uiLanguages || undefined,
@@ -5,16 +5,17 @@ import { useUiStore } from '../stores/ui';
5
5
  import { useRoute, useRouter } from 'vue-router';
6
6
  import { useDsStyle } from '../utils/dataset-style';
7
7
  import { useSiteConfig } from '../config/use-site-config';
8
+ import type { DatasetGroup } from '../config/types';
8
9
  import { useOntologyNav, compactToSlug } from '../composables/use-ontology-nav';
9
10
  import NavIcon from './NavIcon.vue';
10
- import { useI18n } from '../i18n';
11
+ import { useI18n, locale } from '../i18n';
11
12
 
12
13
  const store = useVocabularyStore();
13
14
  const ui = useUiStore();
14
15
  const router = useRouter();
15
16
  const route = useRoute();
16
17
  const { getColor } = useDsStyle();
17
- const { globalPages, datasetPages, config: siteConfig, localizedTitle, localizedDatasetField } = useSiteConfig();
18
+ const { globalPages, datasetPages, config: siteConfig, localizedTitle, localizedDatasetField, datasetGroups } = useSiteConfig();
18
19
  const { t } = useI18n();
19
20
 
20
21
  const currentDataset = computed(() => route.params.registerId as string ?? '');
@@ -85,6 +86,60 @@ const datasetEntries = computed(() => {
85
86
 
86
87
  const datasetIds = computed(() => new Set(datasetEntries.value.map(d => d.id)));
87
88
 
89
+ const hasGroups = computed(() => (datasetGroups.value?.length ?? 0) > 0);
90
+
91
+ interface SidebarGroup {
92
+ id: string;
93
+ label: string;
94
+ description?: string;
95
+ color?: string;
96
+ entries: { id: string; title: string; loaded: boolean; conceptCount: number }[];
97
+ }
98
+
99
+ const groupedDatasetEntries = computed<SidebarGroup[]>(() => {
100
+ const groups = datasetGroups.value;
101
+ if (!groups?.length) return [];
102
+
103
+ const entryMap = new Map(datasetEntries.value.map(e => [e.id, e]));
104
+ const assigned = new Set<string>();
105
+ const result: SidebarGroup[] = [];
106
+
107
+ for (const g of groups) {
108
+ const entries = g.datasets
109
+ .map(id => entryMap.get(id))
110
+ .filter((e): e is typeof entryMap extends Map<string, infer V> ? V : never => !!e);
111
+ for (const e of entries) assigned.add(e.id);
112
+ const trLabel = g.translations?.[locale.value]?.label;
113
+ result.push({
114
+ id: g.id,
115
+ label: trLabel || g.label,
116
+ description: g.description,
117
+ color: g.color,
118
+ entries,
119
+ });
120
+ }
121
+
122
+ const ungrouped = datasetEntries.value.filter(e => !assigned.has(e.id));
123
+ if (ungrouped.length) {
124
+ result.push({ id: '__ungrouped__', label: '', entries: ungrouped });
125
+ }
126
+
127
+ return result;
128
+ });
129
+
130
+ const collapsedGroups = ref<Set<string>>(new Set());
131
+
132
+ function toggleGroup(groupId: string) {
133
+ const s = new Set(collapsedGroups.value);
134
+ if (s.has(groupId)) s.delete(groupId);
135
+ else s.add(groupId);
136
+ collapsedGroups.value = s;
137
+ }
138
+
139
+ function isGroupExpanded(groupId: string): boolean {
140
+ return !collapsedGroups.value.has(groupId);
141
+ }
142
+
88
143
  // Hide dataset-prefixed pages (e.g. "viml-about") from global nav
89
144
  const filteredGlobalPages = computed(() =>
90
145
  globalPages.value.filter(p => {
@@ -473,7 +528,84 @@ function navTitle(page: { route: string }): string {
473
528
 
474
529
  <!-- Datasets -->
475
530
  <div class="section-label">{{ t('nav.datasets') }}</div>
476
- <nav class="space-y-1">
531
+
532
+ <!-- Grouped datasets -->
533
+ <template v-if="hasGroups">
534
+ <div v-for="group in groupedDatasetEntries" :key="group.id" class="mb-2">
535
+ <!-- Group header (skip for ungrouped) -->
536
+ <button
537
+ v-if="group.label"
538
+ @click="toggleGroup(group.id)"
539
+ class="w-full flex items-start gap-1.5 px-2 py-1.5 rounded-lg text-xs font-semibold transition-colors hover:bg-ink-50"
540
+ :style="group.color ? { color: group.color } : {}"
541
+ >
542
+ <span class="w-3 text-[10px] mt-0.5 flex-shrink-0">{{ isGroupExpanded(group.id) ? '▾' : '▸' }}</span>
543
+ <span class="flex-1 text-left leading-snug">{{ group.label }}</span>
544
+ </button>
545
+
546
+ <!-- Group entries -->
547
+ <div v-if="isGroupExpanded(group.id)" class="space-y-1" :class="group.label ? 'ml-1' : ''">
548
+ <div
549
+ v-for="ds in group.entries"
550
+ :key="ds.id"
551
+ class="rounded-lg transition-all duration-150"
552
+ :class="currentDataset === ds.id ? 'bg-surface' : ''"
553
+ >
554
+ <button
555
+ @click="goToDataset(ds.id)"
556
+ class="w-full text-left px-3 py-2 rounded-lg text-sm border-l-2"
557
+ :class="[
558
+ currentDataset === ds.id
559
+ ? 'text-ink-800'
560
+ : 'border-transparent text-ink-600 hover:bg-ink-50 hover:text-ink-800'
561
+ ]"
562
+ :style="currentDataset === ds.id ? { borderLeftColor: getColor(ds.id), borderLeftWidth: '2px' } : {}"
563
+ >
564
+ <div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
565
+ <div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400' : 'text-ink-300'">
566
+ {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
567
+ </div>
568
+ </button>
569
+
570
+ <!-- Expanded dataset: sub-pages + provenance -->
571
+ <div v-if="currentDataset === ds.id && (filteredDatasetPages.length || provenance.owner)" class="px-2 pb-2">
572
+ <nav v-if="filteredDatasetPages.length" class="space-y-0.5 mt-1">
573
+ <router-link
574
+ v-for="page in filteredDatasetPages"
575
+ :key="page.route || 'concepts'"
576
+ :to="pageRoute(page)"
577
+ class="btn-ghost w-full text-left flex items-center gap-2 text-sm"
578
+ :class="isActive(page) ? 'active' : ''"
579
+ @click="closeMobile"
580
+ >
581
+ <NavIcon :name="page.icon" />
582
+ {{ navTitle(page) }}
583
+ </router-link>
584
+ </nav>
585
+
586
+ <div v-if="provenance.owner" class="mt-3 pt-3 border-t border-ink-100/60">
587
+ <div class="text-[11px] text-ink-300 space-y-1.5 px-1">
588
+ <div v-if="provenance.ref" class="text-xs font-semibold text-ink-700">
589
+ {{ provenance.ref }}
590
+ </div>
591
+ <div class="flex items-center gap-1">
592
+ <span class="text-ink-400">{{ t('sidebar.publishedBy') }}</span>
593
+ <a v-if="provenance.ownerUrl" :href="provenance.ownerUrl" target="_blank" rel="noopener" class="concept-link font-medium">{{ provenance.owner }}</a>
594
+ <span v-else class="text-ink-600 font-medium">{{ provenance.owner }}</span>
595
+ </div>
596
+ <div v-if="provenance.sourceRepo">
597
+ <a :href="provenance.sourceRepo" target="_blank" rel="noopener" class="concept-link">{{ t('sidebar.viewSource') }}</a>
598
+ </div>
599
+ </div>
600
+ </div>
601
+ </div>
602
+ </div>
603
+ </div>
604
+ </div>
605
+ </template>
606
+
607
+ <!-- Flat dataset list (fallback when no groups) -->
608
+ <nav v-else class="space-y-1">
477
609
  <div
478
610
  v-for="ds in datasetEntries"
479
611
  :key="ds.id"
@@ -140,6 +140,17 @@ export interface PageConfig {
140
140
  datasetScoped?: boolean;
141
141
  }
142
142
 
143
+ // === Dataset Groups ===
144
+
145
+ export interface DatasetGroup {
146
+ id: string;
147
+ label: string;
148
+ description?: string;
149
+ color?: string;
150
+ datasets: string[];
151
+ translations?: Record<string, { label?: string; description?: string }>;
152
+ }
153
+
143
154
  // === Site Config ===
144
155
 
145
156
  export interface SiteConfig {
@@ -152,6 +163,7 @@ export interface SiteConfig {
152
163
  description?: string;
153
164
  translations?: Record<string, { title?: string; subtitle?: string; description?: string }>;
154
165
  datasets: DatasetConfig[];
166
+ datasetGroups?: DatasetGroup[];
155
167
  routing: RoutingEntry[];
156
168
  branding: SiteBranding;
157
169
  analytics?: AnalyticsConfig;
@@ -1,5 +1,6 @@
1
1
  import { ref, computed } from 'vue';
2
2
  import type { PageConfig } from './types';
3
+ import type { DatasetGroup } from './types';
3
4
  import { locale } from '../i18n';
4
5
 
5
6
  export interface RuntimeSiteConfig {
@@ -12,6 +13,7 @@ export interface RuntimeSiteConfig {
12
13
  translations?: Record<string, { title?: string; subtitle?: string; description?: string }>;
13
14
  datasetTranslations?: Record<string, Record<string, { title?: string; description?: string }>>;
14
15
  datasets: string[];
16
+ datasetGroups?: DatasetGroup[];
15
17
  defaultDataset?: string;
16
18
  uiLanguages?: { code: string; label: string }[];
17
19
  branding?: {
@@ -182,5 +184,5 @@ export function useSiteConfig() {
182
184
  synthesizeDatasetPages(siteConfig.value?.features, siteConfig.value?.pages),
183
185
  );
184
186
 
185
- return { config, visibleDatasets, localizedTitle, localizedSubtitle, localizedDescription, localizedDatasetField, loadConfig, globalPages, datasetPages };
187
+ return { config, visibleDatasets, localizedTitle, localizedSubtitle, localizedDescription, localizedDatasetField, loadConfig, globalPages, datasetPages, datasetGroups: computed(() => siteConfig.value?.datasetGroups) };
186
188
  }