@glossarist/concept-browser 0.7.52 → 0.7.54

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.7.52",
3
+ "version": "0.7.54",
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": {
package/src/App.vue CHANGED
@@ -3,6 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue';
3
3
  import { useVocabularyStore } from './stores/vocabulary';
4
4
  import { useSiteConfig } from './config/use-site-config';
5
5
  import { useI18n } from './i18n';
6
+ import { useColorTheme } from './composables/use-color-theme';
6
7
  import AppHeader from './components/AppHeader.vue';
7
8
  import AppSidebar from './components/AppSidebar.vue';
8
9
  import AppFooter from './components/AppFooter.vue';
@@ -10,6 +11,7 @@ import AppFooter from './components/AppFooter.vue';
10
11
  const store = useVocabularyStore();
11
12
  const { loadConfig, config } = useSiteConfig();
12
13
  const { initLocale } = useI18n();
14
+ useColorTheme();
13
15
  const appReady = ref(false);
14
16
  const showScrollTop = ref(false);
15
17
  const mainRef = ref<HTMLElement | null>(null);
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { GROUP_RENDERERS, groupRendererFor } from '../../config/group-renderers';
3
+ import type { DatasetGroupKind } from '../../config/types';
4
+
5
+ describe('GROUP_RENDERERS registry', () => {
6
+ it('has an entry for every DatasetGroupKind', () => {
7
+ const expected: DatasetGroupKind[] = ['lineage', 'topic', 'family', 'collection', 'default'];
8
+ for (const kind of expected) {
9
+ expect(GROUP_RENDERERS[kind], `missing ${kind}`).toBeDefined();
10
+ expect(GROUP_RENDERERS[kind].sidebar, `missing sidebar for ${kind}`).toBeDefined();
11
+ }
12
+ });
13
+
14
+ it('lineage uses a distinct sidebar component', () => {
15
+ expect(GROUP_RENDERERS.lineage.sidebar).not.toBe(GROUP_RENDERERS.default.sidebar);
16
+ });
17
+
18
+ it('topic, family, collection, default share the DefaultGroupSidebar', () => {
19
+ const defaultSidebar = GROUP_RENDERERS.default.sidebar;
20
+ expect(GROUP_RENDERERS.topic.sidebar).toBe(defaultSidebar);
21
+ expect(GROUP_RENDERERS.family.sidebar).toBe(defaultSidebar);
22
+ expect(GROUP_RENDERERS.collection.sidebar).toBe(defaultSidebar);
23
+ });
24
+ });
25
+
26
+ describe('groupRendererFor', () => {
27
+ it('returns the correct renderer for each kind', () => {
28
+ expect(groupRendererFor('lineage').kind).toBe('lineage');
29
+ expect(groupRendererFor('topic').kind).toBe('topic');
30
+ });
31
+
32
+ it('falls back to default for unknown kinds', () => {
33
+ expect(groupRendererFor('nonexistent' as DatasetGroupKind)).toBe(GROUP_RENDERERS.default);
34
+ });
35
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolveGroupKind, groupTypeMeta, GROUP_TYPES } from '../../config/group-types';
3
+
4
+ describe('resolveGroupKind', () => {
5
+ it('returns explicit kind when set', () => {
6
+ expect(resolveGroupKind({ kind: 'lineage' })).toBe('lineage');
7
+ expect(resolveGroupKind({ kind: 'topic' })).toBe('topic');
8
+ expect(resolveGroupKind({ kind: 'family' })).toBe('family');
9
+ expect(resolveGroupKind({ kind: 'collection' })).toBe('collection');
10
+ expect(resolveGroupKind({ kind: 'default' })).toBe('default');
11
+ });
12
+
13
+ it('returns lineage for legacy series: true', () => {
14
+ expect(resolveGroupKind({ series: true })).toBe('lineage');
15
+ });
16
+
17
+ it('returns default when neither kind nor series is set', () => {
18
+ expect(resolveGroupKind({})).toBe('default');
19
+ });
20
+
21
+ it('prefers explicit kind over legacy series flag', () => {
22
+ expect(resolveGroupKind({ kind: 'topic', series: true })).toBe('topic');
23
+ });
24
+ });
25
+
26
+ describe('groupTypeMeta', () => {
27
+ it('returns metadata for each kind', () => {
28
+ for (const kind of Object.keys(GROUP_TYPES) as Array<keyof typeof GROUP_TYPES>) {
29
+ const meta = groupTypeMeta({ kind });
30
+ expect(meta.kind).toBe(kind);
31
+ expect(meta.label).toBeTruthy();
32
+ expect(meta.description).toBeTruthy();
33
+ expect(meta.glyph).toBeTruthy();
34
+ expect(typeof meta.ordered).toBe('boolean');
35
+ expect(typeof meta.supersession).toBe('boolean');
36
+ expect(typeof meta.sameVocabulary).toBe('boolean');
37
+ }
38
+ });
39
+
40
+ it('lineage is ordered with supersession and same vocabulary', () => {
41
+ const meta = groupTypeMeta({ kind: 'lineage' });
42
+ expect(meta.ordered).toBe(true);
43
+ expect(meta.supersession).toBe(true);
44
+ expect(meta.sameVocabulary).toBe(true);
45
+ });
46
+
47
+ it('topic is not ordered and no supersession', () => {
48
+ const meta = groupTypeMeta({ kind: 'topic' });
49
+ expect(meta.ordered).toBe(false);
50
+ expect(meta.supersession).toBe(false);
51
+ expect(meta.sameVocabulary).toBe(false);
52
+ });
53
+
54
+ it('uses legacy series flag for backward compat', () => {
55
+ const meta = groupTypeMeta({ series: true });
56
+ expect(meta.kind).toBe('lineage');
57
+ });
58
+ });
59
+
60
+ describe('GROUP_TYPES registry', () => {
61
+ it('has an entry for every DatasetGroupKind', () => {
62
+ const expected = ['lineage', 'topic', 'family', 'collection', 'default'];
63
+ for (const kind of expected) {
64
+ expect(GROUP_TYPES).toHaveProperty(kind);
65
+ }
66
+ });
67
+
68
+ it('every entry has all required fields', () => {
69
+ for (const [key, meta] of Object.entries(GROUP_TYPES)) {
70
+ expect(meta.kind, `${key}.kind`).toBe(key);
71
+ expect(meta.label, `${key}.label`).toBeTruthy();
72
+ expect(meta.description, `${key}.description`).toBeTruthy();
73
+ expect(meta.glyph, `${key}.glyph`).toBeTruthy();
74
+ }
75
+ });
76
+ });
@@ -283,132 +283,128 @@ const activeSectionId = computed(() => {
283
283
 
284
284
  <!-- Group entries -->
285
285
  <div v-if="isGroupExpanded(group.id)" class="space-y-1" :class="group.label ? 'ml-1' : ''">
286
- <!-- LINEAGE series: timeline-style entries -->
287
- <template v-if="group.kind === 'lineage'">
288
- <div class="series-timeline">
289
- <button
290
- v-for="ds in group.entries"
291
- :key="ds.id"
292
- @click="goToDataset(ds.id)"
293
- class="series-entry w-full text-left flex items-center gap-2 pl-6 pr-3 py-1.5 rounded-md text-sm border-l-2 transition-all duration-150"
294
- :class="currentDataset === ds.id
295
- ? 'bg-amber-50/70 dark:bg-amber-400/10 border-l-[3px] text-ink-900 dark:text-ink-50 font-semibold'
296
- : 'border-transparent text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-700/40 hover:text-ink-900 dark:hover:text-ink-50'"
297
- :style="currentDataset === ds.id ? { borderLeftColor: 'var(--gold-accent, #B8935A)' } : {}"
298
- >
299
- <span class="flex-1 truncate text-[13.5px] font-medium leading-snug">{{ ds.ref || ds.title || ds.id }}</span>
300
- <span
301
- v-if="ds.status && ds.status !== 'valid'"
302
- class="text-[9px] uppercase tracking-wide italic text-ink-400 dark:text-ink-400"
303
- >{{ ds.status }}</span>
304
- <span
305
- v-if="ds.isCurrent"
306
- class="current-star flex-shrink-0"
307
- title="Current edition"
308
- >✦</span>
309
- </button>
310
- </div>
311
- </template>
312
-
313
- <!-- REGULAR group: original entry style with expansion -->
314
- <template v-else>
315
- <div
316
- v-for="ds in group.entries"
317
- :key="ds.id"
318
- class="rounded-lg transition-all duration-150"
319
- :class="currentDataset === ds.id ? 'bg-surface' : ''"
286
+ <!-- Per-entry rendering: button is kind-specific, expansion is shared (DRY/MECE) -->
287
+ <div
288
+ v-for="ds in group.entries"
289
+ :key="ds.id"
290
+ class="rounded-lg transition-all duration-150"
291
+ :class="currentDataset === ds.id ? 'bg-surface' : ''"
292
+ >
293
+ <!-- LINEAGE: compact timeline button -->
294
+ <button
295
+ v-if="group.kind === 'lineage'"
296
+ @click="goToDataset(ds.id)"
297
+ class="series-entry w-full text-left flex items-center gap-2 pl-6 pr-3 py-1.5 rounded-md text-sm border-l-2 transition-all duration-150"
298
+ :class="currentDataset === ds.id
299
+ ? 'bg-amber-50/70 dark:bg-amber-400/10 border-l-[3px] text-ink-900 dark:text-ink-50 font-semibold'
300
+ : 'border-transparent text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-700/40 hover:text-ink-900 dark:hover:text-ink-50'"
301
+ :style="currentDataset === ds.id ? { borderLeftColor: 'var(--gold-accent, #B8935A)' } : {}"
320
302
  >
321
- <button
322
- @click="goToDataset(ds.id)"
323
- class="w-full text-left px-3 py-2 rounded-lg text-sm border-l-2"
324
- :class="[
325
- currentDataset === ds.id
326
- ? 'text-ink-800 dark:text-ink-50'
327
- : 'border-transparent text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-700 hover:text-ink-800 dark:hover:text-ink-50'
328
- ]"
329
- :style="currentDataset === ds.id ? { borderLeftColor: getColor(ds.id), borderLeftWidth: '2px' } : {}"
330
- >
331
- <div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
332
- <div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400 dark:text-ink-300' : 'text-ink-300 dark:text-ink-400'">
333
- {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
334
- </div>
335
- </button>
303
+ <span class="flex-1 truncate text-[13.5px] font-medium leading-snug">{{ ds.ref || ds.title || ds.id }}</span>
304
+ <span
305
+ v-if="ds.status && ds.status !== 'valid'"
306
+ class="text-[9px] uppercase tracking-wide italic text-ink-400 dark:text-ink-400"
307
+ >{{ ds.status }}</span>
308
+ <span
309
+ v-if="ds.isCurrent"
310
+ class="current-star flex-shrink-0"
311
+ title="Current edition"
312
+ >✦</span>
313
+ </button>
336
314
 
337
- <!-- Expanded dataset: sub-pages + sections + provenance -->
338
- <div v-if="currentDataset === ds.id && (filteredDatasetPages.length || provenance.owner)" class="px-2 pb-2">
339
- <nav v-if="filteredDatasetPages.length" class="space-y-0.5 mt-1">
340
- <router-link
341
- v-for="page in filteredDatasetPages"
342
- :key="page.route || 'concepts'"
343
- :to="pageRoute(page)"
344
- class="btn-ghost w-full text-left flex items-center gap-2 text-sm"
345
- :class="isActive(page) ? 'active' : ''"
346
- @click="closeMobile"
347
- >
348
- <NavIcon :name="page.icon" />
349
- {{ navTitle(page) }}
350
- </router-link>
351
- </nav>
352
-
353
- <!-- Sections tree -->
354
- <div v-if="getDatasetSections(ds.id).length" class="mt-2 pt-2 border-t border-ink-100/60">
355
- <button @click="toggleSectionNode(ds.id + '-sections')"
356
- class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] uppercase tracking-wide text-ink-400 hover:text-ink-600 hover:bg-ink-50 transition-colors"
315
+ <!-- DEFAULT/OTHER: standard entry button -->
316
+ <button
317
+ v-else
318
+ @click="goToDataset(ds.id)"
319
+ class="w-full text-left px-3 py-2 rounded-lg text-sm border-l-2"
320
+ :class="[
321
+ currentDataset === ds.id
322
+ ? 'text-ink-800 dark:text-ink-50'
323
+ : 'border-transparent text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-700 hover:text-ink-800 dark:hover:text-ink-50'
324
+ ]"
325
+ :style="currentDataset === ds.id ? { borderLeftColor: getColor(ds.id), borderLeftWidth: '2px' } : {}"
326
+ >
327
+ <div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
328
+ <div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400 dark:text-ink-300' : 'text-ink-300 dark:text-ink-400'">
329
+ {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
330
+ </div>
331
+ </button>
332
+
333
+ <!-- SHARED expansion content: sub-pages + sections + provenance.
334
+ Appears for the active dataset in ALL group kinds (DRY). -->
335
+ <div v-if="currentDataset === ds.id && (filteredDatasetPages.length || provenance.owner)" class="px-2 pb-2">
336
+ <nav v-if="filteredDatasetPages.length" class="space-y-0.5 mt-1">
337
+ <router-link
338
+ v-for="page in filteredDatasetPages"
339
+ :key="page.route || 'concepts'"
340
+ :to="pageRoute(page)"
341
+ class="btn-ghost w-full text-left flex items-center gap-2 text-sm"
342
+ :class="isActive(page) ? 'active' : ''"
343
+ @click="closeMobile"
344
+ >
345
+ <NavIcon :name="page.icon" />
346
+ {{ navTitle(page) }}
347
+ </router-link>
348
+ </nav>
349
+
350
+ <!-- Sections tree -->
351
+ <div v-if="getDatasetSections(ds.id).length" class="mt-2 pt-2 border-t border-ink-100/60">
352
+ <button @click="toggleSectionNode(ds.id + '-sections')"
353
+ class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] uppercase tracking-wide text-ink-400 hover:text-ink-600 hover:bg-ink-50 transition-colors"
354
+ >
355
+ <span class="w-3 text-[10px]">{{ expandedSectionNodes.has(ds.id + '-sections') ? '▾' : '▸' }}</span>
356
+ <span class="flex-1 text-left">{{ t('nav.sections') }}</span>
357
+ <span class="badge text-[9px] bg-amber-50 text-amber-600 px-1 py-0.5">{{ getDatasetSections(ds.id).length }}</span>
358
+ </button>
359
+ <div v-if="expandedSectionNodes.has(ds.id + '-sections')" class="mt-0.5 max-h-64 overflow-y-auto">
360
+ <button
361
+ @click="clearSectionFilter()"
362
+ class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11px] transition-colors"
363
+ :class="!activeSectionId ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-500 hover:bg-ink-50'"
357
364
  >
358
- <span class="w-3 text-[10px]">{{ expandedSectionNodes.has(ds.id + '-sections') ? '▾' : '▸' }}</span>
359
- <span class="flex-1 text-left">{{ t('nav.sections') }}</span>
360
- <span class="badge text-[9px] bg-amber-50 text-amber-600 px-1 py-0.5">{{ getDatasetSections(ds.id).length }}</span>
365
+ <span class="w-3 text-ink-200">&#183;</span>
366
+ <span class="flex-1 text-left">{{ t('dataset.all') }}</span>
361
367
  </button>
362
- <div v-if="expandedSectionNodes.has(ds.id + '-sections')" class="mt-0.5 max-h-64 overflow-y-auto">
363
- <button
364
- @click="clearSectionFilter()"
368
+ <template v-for="section in getDatasetSections(ds.id)" :key="section.id">
369
+ <button @click="goToSection(ds.id, 'section-' + section.id)"
365
370
  class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11px] transition-colors"
366
- :class="!activeSectionId ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-500 hover:bg-ink-50'"
371
+ :class="activeSectionId === 'section-' + section.id ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-500 hover:bg-ink-50'"
367
372
  >
368
- <span class="w-3 text-ink-200">&#183;</span>
369
- <span class="flex-1 text-left">{{ t('dataset.all') }}</span>
373
+ <span v-if="section.children?.length" class="text-[10px] text-ink-300 w-3 cursor-pointer" @click.stop="toggleSectionNode(ds.id + '-s-' + section.id)">{{ expandedSectionNodes.has(ds.id + '-s-' + section.id) ? '▾' : '▸' }}</span>
374
+ <span v-else class="w-3 text-ink-200">&#183;</span>
375
+ <span class="flex-1 text-left truncate">{{ sectionDisplay(section) }}</span>
370
376
  </button>
371
- <template v-for="section in getDatasetSections(ds.id)" :key="section.id">
372
- <button @click="goToSection(ds.id, 'section-' + section.id)"
377
+ <div v-if="section.children?.length && expandedSectionNodes.has(ds.id + '-s-' + section.id)" class="ml-3">
378
+ <button v-for="child in section.children" :key="child.id"
379
+ @click="goToSection(ds.id, 'section-' + child.id)"
373
380
  class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11px] transition-colors"
374
- :class="activeSectionId === 'section-' + section.id ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-500 hover:bg-ink-50'"
381
+ :class="activeSectionId === 'section-' + child.id ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-400 hover:bg-ink-50'"
375
382
  >
376
- <span v-if="section.children?.length" class="text-[10px] text-ink-300 w-3 cursor-pointer" @click.stop="toggleSectionNode(ds.id + '-s-' + section.id)">{{ expandedSectionNodes.has(ds.id + '-s-' + section.id) ? '▾' : '▸' }}</span>
377
- <span v-else class="w-3 text-ink-200">&#183;</span>
378
- <span class="flex-1 text-left truncate">{{ sectionDisplay(section) }}</span>
383
+ <span class="w-3 text-ink-200">&#183;</span>
384
+ <span class="flex-1 text-left truncate">{{ sectionDisplay(child) }}</span>
379
385
  </button>
380
- <div v-if="section.children?.length && expandedSectionNodes.has(ds.id + '-s-' + section.id)" class="ml-3">
381
- <button v-for="child in section.children" :key="child.id"
382
- @click="goToSection(ds.id, 'section-' + child.id)"
383
- class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11px] transition-colors"
384
- :class="activeSectionId === 'section-' + child.id ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-400 hover:bg-ink-50'"
385
- >
386
- <span class="w-3 text-ink-200">&#183;</span>
387
- <span class="flex-1 text-left truncate">{{ sectionDisplay(child) }}</span>
388
- </button>
389
- </div>
390
- </template>
391
- </div>
386
+ </div>
387
+ </template>
392
388
  </div>
389
+ </div>
393
390
 
394
- <div v-if="provenance.owner" class="mt-3 pt-3 border-t border-ink-100/60">
395
- <div class="text-[11px] text-ink-300 space-y-1.5 px-1">
396
- <div v-if="provenance.ref" class="text-xs font-semibold text-ink-700">
397
- {{ provenance.ref }}
398
- </div>
399
- <div class="flex items-center gap-1">
400
- <span class="text-ink-400">{{ t('sidebar.publishedBy') }}</span>
401
- <a v-if="provenance.ownerUrl" :href="provenance.ownerUrl" target="_blank" rel="noopener" class="concept-link font-medium">{{ provenance.owner }}</a>
402
- <span v-else class="text-ink-600 font-medium">{{ provenance.owner }}</span>
403
- </div>
404
- <div v-if="provenance.sourceRepo">
405
- <a :href="provenance.sourceRepo" target="_blank" rel="noopener" class="concept-link">{{ t('sidebar.viewSource') }}</a>
406
- </div>
391
+ <div v-if="provenance.owner" class="mt-3 pt-3 border-t border-ink-100/60">
392
+ <div class="text-[11px] text-ink-300 space-y-1.5 px-1">
393
+ <div v-if="provenance.ref" class="text-xs font-semibold text-ink-700">
394
+ {{ provenance.ref }}
395
+ </div>
396
+ <div class="flex items-center gap-1">
397
+ <span class="text-ink-400">{{ t('sidebar.publishedBy') }}</span>
398
+ <a v-if="provenance.ownerUrl" :href="provenance.ownerUrl" target="_blank" rel="noopener" class="concept-link font-medium">{{ provenance.owner }}</a>
399
+ <span v-else class="text-ink-600 font-medium">{{ provenance.owner }}</span>
400
+ </div>
401
+ <div v-if="provenance.sourceRepo">
402
+ <a :href="provenance.sourceRepo" target="_blank" rel="noopener" class="concept-link">{{ t('sidebar.viewSource') }}</a>
407
403
  </div>
408
404
  </div>
409
405
  </div>
410
406
  </div>
411
- </template>
407
+ </div>
412
408
  </div>
413
409
  </div>
414
410
  </template>
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * DatasetGroupRenderer — OCP dispatcher. Maps group.kind to the
4
+ * appropriate sidebar renderer component. New kinds: add entry to
5
+ * GROUP_RENDERERS + new component. Zero edits to existing code.
6
+ */
7
+ import { computed } from 'vue';
8
+ import { groupRendererFor } from '../../config/group-renderers';
9
+ import { resolveGroupKind } from '../../config/group-types';
10
+ import type { DatasetGroupKind } from '../../config/types';
11
+
12
+ const props = defineProps<{
13
+ kind: DatasetGroupKind;
14
+ entries: Array<{
15
+ id: string;
16
+ title: string;
17
+ ref?: string;
18
+ loaded: boolean;
19
+ conceptCount: number;
20
+ year?: number;
21
+ status?: string;
22
+ isCurrent?: boolean;
23
+ }>;
24
+ currentDataset: string;
25
+ }>();
26
+
27
+ const renderer = computed(() => groupRendererFor(resolveGroupKind({ kind: props.kind })).sidebar);
28
+ </script>
29
+
30
+ <template>
31
+ <component :is="renderer" :entries="entries" :current-dataset="currentDataset" />
32
+ </template>
@@ -0,0 +1,50 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * DefaultGroupSidebar — flat list of dataset entries with expansion.
4
+ * Used for topic, family, collection, and default group kinds.
5
+ * Replaces the inline v-else template in AppSidebar.
6
+ */
7
+ import { useRouter } from 'vue-router';
8
+ import { useI18n } from '../../i18n';
9
+
10
+ const props = defineProps<{
11
+ entries: Array<{
12
+ id: string;
13
+ title: string;
14
+ loaded: boolean;
15
+ conceptCount: number;
16
+ }>;
17
+ currentDataset: string;
18
+ }>();
19
+
20
+ const router = useRouter();
21
+ const { t } = useI18n();
22
+
23
+ function navigate(id: string) {
24
+ if (id === props.currentDataset) return;
25
+ router.push({ name: 'dataset', params: { registerId: id } });
26
+ }
27
+ </script>
28
+
29
+ <template>
30
+ <div
31
+ v-for="ds in entries"
32
+ :key="ds.id"
33
+ class="rounded-lg transition-all duration-150"
34
+ :class="currentDataset === ds.id ? 'bg-surface' : ''"
35
+ >
36
+ <button
37
+ type="button"
38
+ class="w-full text-left px-3 py-2 rounded-lg text-sm border-l-2"
39
+ :class="currentDataset === ds.id
40
+ ? 'text-ink-800 dark:text-ink-50'
41
+ : 'border-transparent text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-700 hover:text-ink-800 dark:hover:text-ink-50'"
42
+ @click="navigate(ds.id)"
43
+ >
44
+ <div class="font-medium truncate leading-snug">{{ ds.title }}</div>
45
+ <div v-if="ds.loaded" class="text-xs mt-0.5 text-ink-300 dark:text-ink-400">
46
+ {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
47
+ </div>
48
+ </button>
49
+ </div>
50
+ </template>
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * LineageGroupSidebar — timeline-style entries for edition series.
4
+ * Replaces the inline v-if="group.kind === 'lineage'" template in
5
+ * AppSidebar. Open/closed: new group kinds get their own component.
6
+ */
7
+ import { useRouter } from 'vue-router';
8
+
9
+ const props = defineProps<{
10
+ entries: Array<{
11
+ id: string;
12
+ title: string;
13
+ ref?: string;
14
+ loaded: boolean;
15
+ conceptCount: number;
16
+ year?: number;
17
+ status?: string;
18
+ isCurrent?: boolean;
19
+ }>;
20
+ currentDataset: string;
21
+ }>();
22
+
23
+ const router = useRouter();
24
+
25
+ function navigate(id: string) {
26
+ if (id === props.currentDataset) return;
27
+ router.push({ name: 'dataset', params: { registerId: id } });
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <div class="series-timeline">
33
+ <button
34
+ v-for="ds in entries"
35
+ :key="ds.id"
36
+ type="button"
37
+ class="series-entry w-full text-left flex items-center gap-2 pl-6 pr-3 py-1.5 rounded-md text-sm border-l-2 transition-all duration-150"
38
+ :class="currentDataset === ds.id
39
+ ? 'bg-amber-50/70 dark:bg-amber-400/10 border-l-[3px] text-ink-900 dark:text-ink-50 font-semibold'
40
+ : 'border-transparent text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-700/40 hover:text-ink-900 dark:hover:text-ink-50'"
41
+ :style="currentDataset === ds.id ? { borderLeftColor: 'var(--group-kind-lineage-light, #B8935A)' } : {}"
42
+ @click="navigate(ds.id)"
43
+ >
44
+ <span class="flex-1 truncate text-[13.5px] font-medium leading-snug">{{ ds.ref || ds.title || ds.id }}</span>
45
+ <span
46
+ v-if="ds.status && ds.status !== 'valid'"
47
+ class="text-[9px] uppercase tracking-wide italic text-ink-400 dark:text-ink-400"
48
+ >{{ ds.status }}</span>
49
+ <span
50
+ v-if="ds.isCurrent"
51
+ class="current-star flex-shrink-0"
52
+ title="Current edition"
53
+ >✦</span>
54
+ </button>
55
+ </div>
56
+ </template>
57
+
58
+ <style scoped>
59
+ .series-entry {
60
+ position: relative;
61
+ }
62
+ .current-star {
63
+ display: inline-flex;
64
+ align-items: center;
65
+ justify-content: center;
66
+ font-size: 16px;
67
+ line-height: 1;
68
+ color: var(--group-kind-lineage-light, #B8935A);
69
+ filter: drop-shadow(0 0 4px rgba(184, 147, 90, 0.45));
70
+ }
71
+ :global(.dark) .current-star {
72
+ color: var(--group-kind-lineage-dark, #D4AF6E);
73
+ filter: drop-shadow(0 0 4px rgba(212, 175, 110, 0.55));
74
+ }
75
+ </style>
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Group renderer registry — maps each DatasetGroupKind to its sidebar
3
+ * and homePage renderer components. Open/closed: adding a new kind
4
+ * requires one entry here + one new component. Zero edits to existing
5
+ * components.
6
+ */
7
+ import type { Component } from 'vue';
8
+ import type { DatasetGroupKind } from './types';
9
+ import LineageGroupSidebar from '../components/groups/LineageGroupSidebar.vue';
10
+ import DefaultGroupSidebar from '../components/groups/DefaultGroupSidebar.vue';
11
+
12
+ export interface GroupRendererEntry {
13
+ readonly kind: DatasetGroupKind;
14
+ readonly sidebar: Component;
15
+ }
16
+
17
+ export const GROUP_RENDERERS: Record<DatasetGroupKind, GroupRendererEntry> = {
18
+ lineage: { kind: 'lineage', sidebar: LineageGroupSidebar },
19
+ topic: { kind: 'topic', sidebar: DefaultGroupSidebar },
20
+ family: { kind: 'family', sidebar: DefaultGroupSidebar },
21
+ collection: { kind: 'collection', sidebar: DefaultGroupSidebar },
22
+ default: { kind: 'default', sidebar: DefaultGroupSidebar },
23
+ };
24
+
25
+ export function groupRendererFor(kind: DatasetGroupKind): GroupRendererEntry {
26
+ return GROUP_RENDERERS[kind] ?? GROUP_RENDERERS.default;
27
+ }
@@ -52,7 +52,9 @@ const concept = computed(() => store.currentConcept);
52
52
  const manifest = computed(() => store.currentManifest);
53
53
  const edges = computed(() => store.conceptEdges);
54
54
  const adjacent = ref({ prev: null as string | null, next: null as string | null });
55
- const viewMode = ref<'detail' | 'sphere'>('detail');
55
+ const viewMode = ref<'detail' | 'sphere'>(
56
+ router.currentRoute.value.query.view === 'sphere' ? 'sphere' : 'detail'
57
+ );
56
58
 
57
59
  /* When the user clicks a card in the sphere, we store the navigation
58
60
  payload here. The concept loads via store.viewConcept (without
@@ -111,10 +113,12 @@ function onSphereNavigate(payload: { registerId: string; conceptId: string }) {
111
113
  function switchToSphere() {
112
114
  viewMode.value = 'sphere';
113
115
  sphereFocusPayload.value = null;
116
+ router.replace({ query: { ...router.currentRoute.value.query, view: 'sphere' } });
114
117
  }
115
118
 
116
119
  function switchToDetail() {
117
120
  viewMode.value = 'detail';
121
+ router.replace({ query: { ...router.currentRoute.value.query, view: 'detail' } });
118
122
  /* Commit the URL if the sphere navigated to a different concept.
119
123
  This triggers loadConcept → the Detail view shows the right concept. */
120
124
  if (sphereFocusPayload.value) {