@glossarist/concept-browser 0.7.52 → 0.7.53
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/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.53",
|
|
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
|
+
});
|
|
@@ -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) {
|