@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 +1 -1
- package/src/App.vue +2 -0
- package/src/__tests__/config/group-renderers.test.ts +35 -0
- package/src/__tests__/config/group-types.test.ts +76 -0
- package/src/components/AppSidebar.vue +106 -110
- package/src/components/groups/DatasetGroupRenderer.vue +32 -0
- package/src/components/groups/DefaultGroupSidebar.vue +50 -0
- package/src/components/groups/LineageGroupSidebar.vue +75 -0
- package/src/config/group-renderers.ts +27 -0
- package/src/views/ConceptView.vue +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
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
|
-
<!--
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
<
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
>
|
|
331
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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-
|
|
359
|
-
<span class="flex-1 text-left">{{ t('
|
|
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">·</span>
|
|
366
|
+
<span class="flex-1 text-left">{{ t('dataset.all') }}</span>
|
|
361
367
|
</button>
|
|
362
|
-
<
|
|
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="
|
|
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="
|
|
369
|
-
<span class="
|
|
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">·</span>
|
|
375
|
+
<span class="flex-1 text-left truncate">{{ sectionDisplay(section) }}</span>
|
|
370
376
|
</button>
|
|
371
|
-
<
|
|
372
|
-
<button
|
|
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-' +
|
|
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
|
|
377
|
-
<span
|
|
378
|
-
<span class="flex-1 text-left truncate">{{ sectionDisplay(section) }}</span>
|
|
383
|
+
<span class="w-3 text-ink-200">·</span>
|
|
384
|
+
<span class="flex-1 text-left truncate">{{ sectionDisplay(child) }}</span>
|
|
379
385
|
</button>
|
|
380
|
-
|
|
381
|
-
|
|
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">·</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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
</
|
|
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'>(
|
|
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) {
|