@glossarist/concept-browser 0.5.1 → 0.7.0
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/cli/index.mjs +17 -0
- package/package.json +1 -1
- package/scripts/generate-data.mjs +49 -0
- package/src/App.vue +3 -0
- package/src/__tests__/markdown-lite.test.ts +26 -0
- package/src/components/AppFooter.vue +3 -1
- package/src/components/AppHeader.vue +48 -6
- package/src/components/AppSidebar.vue +17 -8
- package/src/components/ConceptCard.vue +5 -2
- package/src/components/ConceptDetail.vue +14 -11
- package/src/components/GraphPanel.vue +18 -16
- package/src/components/LanguageDetail.vue +11 -8
- package/src/components/SearchBar.vue +12 -11
- package/src/config/types.ts +2 -0
- package/src/config/use-site-config.ts +25 -1
- package/src/i18n/index.ts +51 -0
- package/src/i18n/locales/eng.yml +128 -0
- package/src/i18n/locales/fra.yml +128 -0
- package/src/shims/glossarist-tags.ts +10 -0
- package/src/style.css +12 -0
- package/src/utils/markdown-lite.ts +15 -0
- package/src/views/AboutView.vue +3 -1
- package/src/views/ContributorsView.vue +5 -1
- package/src/views/DatasetView.vue +22 -20
- package/src/views/GraphView.vue +6 -4
- package/src/views/HomeView.vue +23 -19
- package/src/views/NewsView.vue +3 -1
- package/src/views/PageView.vue +26 -11
- package/src/views/SearchView.vue +4 -2
- package/src/views/StatsView.vue +11 -9
- package/vite.config.ts +34 -1
package/cli/index.mjs
CHANGED
|
@@ -148,6 +148,23 @@ Environment:
|
|
|
148
148
|
process.env.FAVICON_HTML = faviconHtml;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// Pass branding info for HTML transformation
|
|
152
|
+
process.env.SITE_TITLE = config?.title || 'Glossarist';
|
|
153
|
+
if (branding.fonts?.header || branding.fonts?.body) {
|
|
154
|
+
const fontFamilies = [];
|
|
155
|
+
if (branding.fonts.header?.source === 'google') {
|
|
156
|
+
const w = (branding.fonts.header.weights || [400, 700]).join(';');
|
|
157
|
+
fontFamilies.push(`family=${branding.fonts.header.family.replace(/ /g, '+')}:wght@${w}`);
|
|
158
|
+
}
|
|
159
|
+
if (branding.fonts.body?.source === 'google') {
|
|
160
|
+
const w = (branding.fonts.body.weights || [400, 700]).join(';');
|
|
161
|
+
fontFamilies.push(`family=${branding.fonts.body.family.replace(/ /g, '+')}:wght@${w}`);
|
|
162
|
+
}
|
|
163
|
+
if (fontFamilies.length) {
|
|
164
|
+
process.env.SITE_FONTS_URL = `https://fonts.googleapis.com/css2?${fontFamilies.join('&')}&display=swap`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
151
168
|
// Run vite build using the package's vite.config.ts
|
|
152
169
|
console.log(`\n=== BUILD SPA ===\n`);
|
|
153
170
|
const viteConfig = resolve(pkgRoot, 'vite.config.ts');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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": {
|
|
@@ -591,6 +591,10 @@ function processDataset(dir, register, opts) {
|
|
|
591
591
|
console.log(`Processing ${register}: ${files.length} files`);
|
|
592
592
|
|
|
593
593
|
const conceptsDir = path.join(DATA, register, 'concepts');
|
|
594
|
+
// Clean previous output to avoid stale files accumulating across runs
|
|
595
|
+
if (fs.existsSync(conceptsDir)) {
|
|
596
|
+
for (const f of fs.readdirSync(conceptsDir)) fs.unlinkSync(path.join(conceptsDir, f));
|
|
597
|
+
}
|
|
594
598
|
const concepts = [];
|
|
595
599
|
const langTermCounts = {};
|
|
596
600
|
const langDefCounts = {};
|
|
@@ -994,6 +998,19 @@ function renderMarkdown(input) {
|
|
|
994
998
|
while (i < lines.length && /^>\s?/.test(lines[i])) { ql.push(lines[i].replace(/^>\s?/, '')); i++; }
|
|
995
999
|
blocks.push(`<blockquote>${renderInline(ql.join(' '))}</blockquote>`); continue;
|
|
996
1000
|
}
|
|
1001
|
+
if (/^\|(.+)\|$/.test(line) && i + 1 < lines.length && /^\|[-:| ]+\|$/.test(lines[i + 1])) {
|
|
1002
|
+
const headerCells = line.split('|').map(c => c.trim()).filter(Boolean);
|
|
1003
|
+
i += 2;
|
|
1004
|
+
const rows = [];
|
|
1005
|
+
while (i < lines.length && /^\|(.+)\|$/.test(lines[i])) {
|
|
1006
|
+
rows.push(lines[i].split('|').map(c => c.trim()).filter(Boolean));
|
|
1007
|
+
i++;
|
|
1008
|
+
}
|
|
1009
|
+
const thCells = headerCells.map(c => `<th>${renderInline(c)}</th>`).join('');
|
|
1010
|
+
const trRows = rows.map(r => `<tr>${r.map(c => `<td>${renderInline(c)}</td>`).join('')}</tr>`).join('');
|
|
1011
|
+
blocks.push(`<table><thead><tr>${thCells}</tr></thead><tbody>${trRows}</tbody></table>`);
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
997
1014
|
if (!line.trim()) { i++; continue; }
|
|
998
1015
|
const pl = [];
|
|
999
1016
|
while (i < lines.length && lines[i].trim() && !/^#{1,4}\s/.test(lines[i]) && !/^\s*[-*]\s+/.test(lines[i]) && !/^\s*\d+\.\s+/.test(lines[i]) && !/^>\s?/.test(lines[i]) && !lines[i].trimStart().startsWith('```')) { pl.push(lines[i]); i++; }
|
|
@@ -1026,6 +1043,29 @@ function processContentPage(config, page) {
|
|
|
1026
1043
|
fs.mkdirSync(pagesDir, { recursive: true });
|
|
1027
1044
|
writeJson(path.join(pagesDir, `${page.route}.json`), { title: page.title, html });
|
|
1028
1045
|
console.log(` Generated content page: ${page.route} (${ext})`);
|
|
1046
|
+
|
|
1047
|
+
// Generate localized versions
|
|
1048
|
+
if (page.translations) {
|
|
1049
|
+
for (const [lang, tr] of Object.entries(page.translations)) {
|
|
1050
|
+
const { source, title: trTitle } = tr;
|
|
1051
|
+
if (!source) continue;
|
|
1052
|
+
const trSrcPath = path.resolve(ROOT, source);
|
|
1053
|
+
if (!fs.existsSync(trSrcPath)) {
|
|
1054
|
+
console.warn(` Skipping '${page.route}' translation '${lang}': source not found (${trSrcPath})`);
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
const trRaw = fs.readFileSync(trSrcPath, 'utf8');
|
|
1058
|
+
const trExt = path.extname(trSrcPath).toLowerCase();
|
|
1059
|
+
let trHtml;
|
|
1060
|
+
if (trExt === '.html' || trExt === '.htm') {
|
|
1061
|
+
trHtml = trRaw;
|
|
1062
|
+
} else {
|
|
1063
|
+
trHtml = renderMarkdown(stripFrontmatter(trRaw));
|
|
1064
|
+
}
|
|
1065
|
+
writeJson(path.join(pagesDir, `${page.route}.${lang}.json`), { title: trTitle || page.title, html: trHtml });
|
|
1066
|
+
console.log(` Generated localized page: ${page.route}.${lang} (${trExt})`);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1029
1069
|
}
|
|
1030
1070
|
|
|
1031
1071
|
function stripFrontmatter(text) {
|
|
@@ -1073,14 +1113,23 @@ for (const key of ['logo', 'footerLogo']) {
|
|
|
1073
1113
|
}
|
|
1074
1114
|
}
|
|
1075
1115
|
|
|
1116
|
+
// Build dataset translations map
|
|
1117
|
+
const datasetTranslations = {};
|
|
1118
|
+
for (const d of config.datasets) {
|
|
1119
|
+
if (d.translations) datasetTranslations[d.id] = d.translations;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1076
1122
|
writeJson(path.join(PUBLIC, 'site-config.json'), {
|
|
1077
1123
|
id: config.id,
|
|
1078
1124
|
domain: config.domain,
|
|
1079
1125
|
title: config.title,
|
|
1080
1126
|
subtitle: config.subtitle,
|
|
1081
1127
|
description: config.description,
|
|
1128
|
+
translations: config.translations || undefined,
|
|
1082
1129
|
datasets: config.datasets.map(d => d.id),
|
|
1130
|
+
datasetTranslations: Object.keys(datasetTranslations).length ? datasetTranslations : undefined,
|
|
1083
1131
|
defaultDataset: config.datasets.length === 1 ? config.datasets[0].id : undefined,
|
|
1132
|
+
uiLanguages: config.uiLanguages || undefined,
|
|
1084
1133
|
branding: siteBranding,
|
|
1085
1134
|
analytics: config.analytics,
|
|
1086
1135
|
features: config.features,
|
package/src/App.vue
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
import { onMounted, onUnmounted, ref } from 'vue';
|
|
3
3
|
import { useVocabularyStore } from './stores/vocabulary';
|
|
4
4
|
import { useSiteConfig } from './config/use-site-config';
|
|
5
|
+
import { useI18n } from './i18n';
|
|
5
6
|
import AppHeader from './components/AppHeader.vue';
|
|
6
7
|
import AppSidebar from './components/AppSidebar.vue';
|
|
7
8
|
import AppFooter from './components/AppFooter.vue';
|
|
8
9
|
|
|
9
10
|
const store = useVocabularyStore();
|
|
10
11
|
const { loadConfig, config } = useSiteConfig();
|
|
12
|
+
const { initLocale } = useI18n();
|
|
11
13
|
const appReady = ref(false);
|
|
12
14
|
const showScrollTop = ref(false);
|
|
13
15
|
let mainEl: HTMLElement | null = null;
|
|
@@ -25,6 +27,7 @@ onMounted(async () => {
|
|
|
25
27
|
if (cfg?.title) {
|
|
26
28
|
document.title = cfg.title;
|
|
27
29
|
}
|
|
30
|
+
initLocale(cfg?.defaults?.language);
|
|
28
31
|
appReady.value = true;
|
|
29
32
|
// Watch scroll on main content area
|
|
30
33
|
mainEl = document.querySelector('main');
|
|
@@ -85,4 +85,30 @@ describe('renderMarkdown', () => {
|
|
|
85
85
|
expect(result).toContain('<p>line one line two</p>');
|
|
86
86
|
expect(result).toContain('<p>new paragraph</p>');
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
it('renders markdown tables', () => {
|
|
90
|
+
const input = '| Name | Value |\n|------|-------|\n| a | 1 |\n| b | 2 |';
|
|
91
|
+
const result = renderMarkdown(input);
|
|
92
|
+
expect(result).toContain('<table>');
|
|
93
|
+
expect(result).toContain('<thead>');
|
|
94
|
+
expect(result).toContain('<th>Name</th>');
|
|
95
|
+
expect(result).toContain('<th>Value</th>');
|
|
96
|
+
expect(result).toContain('<tbody>');
|
|
97
|
+
expect(result).toContain('<td>a</td>');
|
|
98
|
+
expect(result).toContain('<td>b</td>');
|
|
99
|
+
expect(result).toContain('</table>');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('renders table cells with inline formatting', () => {
|
|
103
|
+
const input = '| Col |\n|-----|\n| **bold** |';
|
|
104
|
+
const result = renderMarkdown(input);
|
|
105
|
+
expect(result).toContain('<td><strong>bold</strong></td>');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('does not treat non-table pipe lines as tables', () => {
|
|
109
|
+
const input = 'some text | with pipes';
|
|
110
|
+
const result = renderMarkdown(input);
|
|
111
|
+
expect(result).toContain('<p>');
|
|
112
|
+
expect(result).not.toContain('<table>');
|
|
113
|
+
});
|
|
88
114
|
});
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue';
|
|
3
3
|
import { useSiteConfig } from '../config/use-site-config';
|
|
4
|
+
import { useI18n } from '../i18n';
|
|
4
5
|
|
|
5
6
|
const { config } = useSiteConfig();
|
|
7
|
+
const { t } = useI18n();
|
|
6
8
|
|
|
7
9
|
const poweredBy = computed(() => {
|
|
8
10
|
const pb = config.value?.features?.poweredBy as { message?: string; url?: string } | undefined;
|
|
9
|
-
return { message: pb?.message || '
|
|
11
|
+
return { message: pb?.message || t('footer.builtWith'), url: pb?.url || 'https://github.com/glossarist/concept-browser' };
|
|
10
12
|
});
|
|
11
13
|
|
|
12
14
|
const socialLinks = computed(() => {
|
|
@@ -3,13 +3,23 @@ import { useRouter } from 'vue-router';
|
|
|
3
3
|
import { useUiStore } from '../stores/ui';
|
|
4
4
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
5
5
|
import { useSiteConfig } from '../config/use-site-config';
|
|
6
|
-
import {
|
|
6
|
+
import { useI18n } from '../i18n';
|
|
7
|
+
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
|
7
8
|
|
|
8
9
|
const router = useRouter();
|
|
9
10
|
const ui = useUiStore();
|
|
10
11
|
const store = useVocabularyStore();
|
|
11
|
-
const { config: siteConfig } = useSiteConfig();
|
|
12
|
+
const { config: siteConfig, localizedTitle } = useSiteConfig();
|
|
13
|
+
const { locale, t, setLocale } = useI18n();
|
|
12
14
|
const searchInput = ref('');
|
|
15
|
+
const langOpen = ref(false);
|
|
16
|
+
|
|
17
|
+
const uiLanguages = computed(() => siteConfig.value?.uiLanguages || []);
|
|
18
|
+
const showLangSelector = computed(() => uiLanguages.value.length > 1);
|
|
19
|
+
const currentLangLabel = computed(() => {
|
|
20
|
+
const cur = uiLanguages.value.find(l => l.code === locale.value);
|
|
21
|
+
return cur?.label || locale.value.toUpperCase();
|
|
22
|
+
});
|
|
13
23
|
|
|
14
24
|
function doSearch() {
|
|
15
25
|
const q = searchInput.value.trim();
|
|
@@ -22,6 +32,18 @@ function doSearch() {
|
|
|
22
32
|
function goHome() {
|
|
23
33
|
router.push({ name: 'home' });
|
|
24
34
|
}
|
|
35
|
+
|
|
36
|
+
function selectLang(code: string) {
|
|
37
|
+
setLocale(code);
|
|
38
|
+
langOpen.value = false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function closeLangOnOutside(e: MouseEvent) {
|
|
42
|
+
if (langOpen.value) langOpen.value = false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onMounted(() => document.addEventListener('click', closeLangOnOutside));
|
|
46
|
+
onBeforeUnmount(() => document.removeEventListener('click', closeLangOnOutside));
|
|
25
47
|
</script>
|
|
26
48
|
|
|
27
49
|
<template>
|
|
@@ -55,7 +77,7 @@ function goHome() {
|
|
|
55
77
|
<line x1="9" y1="11" x2="15" y2="11"/>
|
|
56
78
|
</svg>
|
|
57
79
|
</div>
|
|
58
|
-
<span class="font-serif text-lg text-ink-800 leading-none hidden sm:inline">{{
|
|
80
|
+
<span class="font-serif text-lg text-ink-800 leading-none hidden sm:inline">{{ localizedTitle }}</span>
|
|
59
81
|
</button>
|
|
60
82
|
|
|
61
83
|
<!-- Search -->
|
|
@@ -64,8 +86,8 @@ function goHome() {
|
|
|
64
86
|
<input
|
|
65
87
|
v-model="searchInput"
|
|
66
88
|
type="text"
|
|
67
|
-
aria-label="
|
|
68
|
-
placeholder="
|
|
89
|
+
:aria-label="t('search.conceptSearch')"
|
|
90
|
+
:placeholder="t('search.placeholder')"
|
|
69
91
|
class="w-full pl-9 pr-3 py-2 text-sm bg-surface border border-ink-100 rounded-lg focus:ring-2 focus:ring-ink-200 focus:border-ink-400 outline-none placeholder:text-ink-300 transition-all"
|
|
70
92
|
/>
|
|
71
93
|
<svg class="absolute left-3 top-2.5 w-4 h-4 text-ink-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
@@ -76,7 +98,27 @@ function goHome() {
|
|
|
76
98
|
|
|
77
99
|
<!-- Stats -->
|
|
78
100
|
<div class="text-xs text-ink-400 flex-shrink-0 hidden md:block">
|
|
79
|
-
{{ store.datasetList.length }} datasets
|
|
101
|
+
{{ store.datasetList.length }} {{ t('header.datasets') }}
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<!-- Language selector -->
|
|
105
|
+
<div v-if="showLangSelector" class="relative flex-shrink-0" @click.stop>
|
|
106
|
+
<button
|
|
107
|
+
@click="langOpen = !langOpen"
|
|
108
|
+
class="px-2.5 py-1 rounded-lg text-xs font-semibold text-ink-500 hover:text-ink-700 hover:bg-ink-50 transition-colors border border-ink-100 flex items-center gap-1"
|
|
109
|
+
>
|
|
110
|
+
{{ currentLangLabel }}
|
|
111
|
+
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
|
112
|
+
</button>
|
|
113
|
+
<div v-if="langOpen" class="absolute right-0 top-full mt-1 bg-surface-raised border border-ink-100 rounded-lg shadow-lg py-1 min-w-[120px] z-50">
|
|
114
|
+
<button
|
|
115
|
+
v-for="lang in uiLanguages"
|
|
116
|
+
:key="lang.code"
|
|
117
|
+
@click="selectLang(lang.code)"
|
|
118
|
+
class="w-full text-left px-3 py-1.5 text-sm transition-colors"
|
|
119
|
+
:class="locale === lang.code ? 'bg-ink-50 text-ink-800 font-medium' : 'text-ink-600 hover:bg-ink-50'"
|
|
120
|
+
>{{ lang.label }}</button>
|
|
121
|
+
</div>
|
|
80
122
|
</div>
|
|
81
123
|
|
|
82
124
|
<!-- Theme toggle -->
|
|
@@ -7,13 +7,15 @@ import { useDsStyle } from '../utils/dataset-style';
|
|
|
7
7
|
import { useSiteConfig } from '../config/use-site-config';
|
|
8
8
|
import { useOntologyNav, compactToSlug } from '../composables/use-ontology-nav';
|
|
9
9
|
import NavIcon from './NavIcon.vue';
|
|
10
|
+
import { useI18n } from '../i18n';
|
|
10
11
|
|
|
11
12
|
const store = useVocabularyStore();
|
|
12
13
|
const ui = useUiStore();
|
|
13
14
|
const router = useRouter();
|
|
14
15
|
const route = useRoute();
|
|
15
16
|
const { getColor } = useDsStyle();
|
|
16
|
-
const { globalPages, datasetPages, config: siteConfig } = useSiteConfig();
|
|
17
|
+
const { globalPages, datasetPages, config: siteConfig, localizedTitle, localizedDatasetField } = useSiteConfig();
|
|
18
|
+
const { t } = useI18n();
|
|
17
19
|
|
|
18
20
|
const currentDataset = computed(() => route.params.registerId as string ?? '');
|
|
19
21
|
|
|
@@ -125,6 +127,13 @@ function selectProperty(id: string) {
|
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
const isSearching = computed(() => !!searchQuery.value.trim());
|
|
130
|
+
|
|
131
|
+
function navTitle(page: { route: string }): string {
|
|
132
|
+
const route = page.route || 'home';
|
|
133
|
+
const key = `nav.${route}`;
|
|
134
|
+
const translated = t(key);
|
|
135
|
+
return translated === key ? (page as any).title : translated;
|
|
136
|
+
}
|
|
128
137
|
</script>
|
|
129
138
|
|
|
130
139
|
<template>
|
|
@@ -139,7 +148,7 @@ const isSearching = computed(() => !!searchQuery.value.trim());
|
|
|
139
148
|
>
|
|
140
149
|
<div class="p-4">
|
|
141
150
|
<!-- Navigation -->
|
|
142
|
-
<div class="section-label">
|
|
151
|
+
<div class="section-label">{{ t('nav.navigation') }}</div>
|
|
143
152
|
<nav class="space-y-0.5 mb-6">
|
|
144
153
|
<template v-for="page in globalPages" :key="page.route || 'home'">
|
|
145
154
|
<router-link
|
|
@@ -149,7 +158,7 @@ const isSearching = computed(() => !!searchQuery.value.trim());
|
|
|
149
158
|
@click="closeMobile"
|
|
150
159
|
>
|
|
151
160
|
<NavIcon :name="page.icon" />
|
|
152
|
-
{{ page
|
|
161
|
+
{{ navTitle(page) }}
|
|
153
162
|
</router-link>
|
|
154
163
|
|
|
155
164
|
<!-- Ontology entity sections nested under Ontology nav item -->
|
|
@@ -422,7 +431,7 @@ const isSearching = computed(() => !!searchQuery.value.trim());
|
|
|
422
431
|
|
|
423
432
|
<!-- Dataset-level navigation (shown when viewing a dataset) -->
|
|
424
433
|
<div v-if="showDatasetNav" class="mb-6">
|
|
425
|
-
<div class="section-label">{{ currentManifest?.title || siteConfig?.title || 'Dataset' }}</div>
|
|
434
|
+
<div class="section-label">{{ localizedDatasetField(currentDataset, 'title', currentManifest?.title || siteConfig?.title || 'Dataset') }}</div>
|
|
426
435
|
<nav class="space-y-0.5">
|
|
427
436
|
<router-link
|
|
428
437
|
v-for="page in datasetPages"
|
|
@@ -433,13 +442,13 @@ const isSearching = computed(() => !!searchQuery.value.trim());
|
|
|
433
442
|
@click="closeMobile"
|
|
434
443
|
>
|
|
435
444
|
<NavIcon :name="page.icon" />
|
|
436
|
-
{{ page
|
|
445
|
+
{{ navTitle(page) }}
|
|
437
446
|
</router-link>
|
|
438
447
|
</nav>
|
|
439
448
|
</div>
|
|
440
449
|
|
|
441
450
|
<!-- Datasets -->
|
|
442
|
-
<div class="section-label">
|
|
451
|
+
<div class="section-label">{{ t('nav.datasets') }}</div>
|
|
443
452
|
<nav class="space-y-1">
|
|
444
453
|
<button
|
|
445
454
|
v-for="ds in datasetEntries"
|
|
@@ -453,9 +462,9 @@ const isSearching = computed(() => !!searchQuery.value.trim());
|
|
|
453
462
|
]"
|
|
454
463
|
:style="currentDataset === ds.id ? { borderLeftColor: getColor(ds.id), borderLeftWidth: '2px' } : {}"
|
|
455
464
|
>
|
|
456
|
-
<div class="font-medium truncate leading-snug">{{ ds.title }}</div>
|
|
465
|
+
<div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
|
|
457
466
|
<div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400' : 'text-ink-300'">
|
|
458
|
-
{{ ds.conceptCount.toLocaleString() }} concepts
|
|
467
|
+
{{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
|
|
459
468
|
</div>
|
|
460
469
|
</button>
|
|
461
470
|
</nav>
|
|
@@ -4,6 +4,7 @@ import { computed } from 'vue';
|
|
|
4
4
|
import { useRouter } from 'vue-router';
|
|
5
5
|
import { useDsStyle } from '../utils/dataset-style';
|
|
6
6
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
7
|
+
import { useI18n } from '../i18n';
|
|
7
8
|
|
|
8
9
|
const props = defineProps<{
|
|
9
10
|
entry: ConceptSummary;
|
|
@@ -14,6 +15,7 @@ const props = defineProps<{
|
|
|
14
15
|
const router = useRouter();
|
|
15
16
|
const { getColor } = useDsStyle();
|
|
16
17
|
const store = useVocabularyStore();
|
|
18
|
+
const { locale } = useI18n();
|
|
17
19
|
|
|
18
20
|
function viewConcept() {
|
|
19
21
|
router.push({
|
|
@@ -32,8 +34,9 @@ function statusColor(status: string): string {
|
|
|
32
34
|
const manifestLanguages = computed(() => store.manifests.get(props.registerId)?.languages ?? []);
|
|
33
35
|
|
|
34
36
|
const displayTitle = computed(() => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
const lang = props.displayLang || locale.value;
|
|
38
|
+
if (props.entry.designations?.[lang]) {
|
|
39
|
+
return props.entry.designations[lang];
|
|
37
40
|
}
|
|
38
41
|
return props.entry.eng || props.entry.id;
|
|
39
42
|
});
|
|
@@ -21,6 +21,9 @@ import ConceptRdfView from './ConceptRdfView.vue';
|
|
|
21
21
|
import FormatDownloads from './FormatDownloads.vue';
|
|
22
22
|
import NonVerbalRepDisplay from './NonVerbalRepDisplay.vue';
|
|
23
23
|
import CitationDisplay from './CitationDisplay.vue';
|
|
24
|
+
import { useI18n } from '../i18n';
|
|
25
|
+
|
|
26
|
+
const { t } = useI18n();
|
|
24
27
|
|
|
25
28
|
const props = defineProps<{
|
|
26
29
|
concept: Concept;
|
|
@@ -363,7 +366,7 @@ const nonVerbalReps = computed(() => {
|
|
|
363
366
|
<!-- Breadcrumb + nav row -->
|
|
364
367
|
<div class="flex items-start gap-2 mb-3">
|
|
365
368
|
<nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 min-w-0 flex-1 flex-wrap">
|
|
366
|
-
<router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors whitespace-nowrap">
|
|
369
|
+
<router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors whitespace-nowrap">{{ t('nav.home') }}</router-link>
|
|
367
370
|
<span class="text-ink-200">/</span>
|
|
368
371
|
<router-link :to="{ name: 'dataset', params: { registerId: manifest.id }}" class="hover:text-ink-700 transition-colors truncate max-w-[180px]">
|
|
369
372
|
{{ manifest.title }}
|
|
@@ -563,7 +566,7 @@ const nonVerbalReps = computed(() => {
|
|
|
563
566
|
<!-- Notes -->
|
|
564
567
|
<div v-if="lc.notes.length" class="space-y-2">
|
|
565
568
|
<div v-for="(note, i) in lc.notes" :key="i" class="text-ink-600 text-sm leading-relaxed">
|
|
566
|
-
<span class="font-medium text-ink-400 text-xs uppercase tracking-wide">
|
|
569
|
+
<span class="font-medium text-ink-400 text-xs uppercase tracking-wide">{{ t('concept.note') }} {{ i + 1 }}</span>
|
|
567
570
|
<div class="mt-1" v-html="renderMath(note, renderOpts)"></div>
|
|
568
571
|
</div>
|
|
569
572
|
</div>
|
|
@@ -571,7 +574,7 @@ const nonVerbalReps = computed(() => {
|
|
|
571
574
|
<!-- Examples -->
|
|
572
575
|
<div v-if="lc.examples.length" class="space-y-2">
|
|
573
576
|
<div v-for="(ex, i) in lc.examples" :key="i" class="text-ink-600 text-sm leading-relaxed">
|
|
574
|
-
<span class="font-medium text-ink-400 text-xs uppercase tracking-wide">
|
|
577
|
+
<span class="font-medium text-ink-400 text-xs uppercase tracking-wide">{{ t('concept.example') }} {{ i + 1 }}</span>
|
|
575
578
|
<div class="mt-1" v-html="renderMath(ex, renderOpts)"></div>
|
|
576
579
|
</div>
|
|
577
580
|
</div>
|
|
@@ -635,9 +638,9 @@ const nonVerbalReps = computed(() => {
|
|
|
635
638
|
<div class="w-full lg:w-64 flex-shrink-0 space-y-4 mt-6 lg:mt-0">
|
|
636
639
|
<!-- Relations -->
|
|
637
640
|
<div v-if="outgoingEdges.length || incomingEdges.length" class="card p-5">
|
|
638
|
-
<div class="section-label">
|
|
641
|
+
<div class="section-label">{{ t('concept.relations') }}</div>
|
|
639
642
|
<div v-if="outgoingEdges.length" class="mt-3">
|
|
640
|
-
<div class="text-xs text-ink-300 mb-2">
|
|
643
|
+
<div class="text-xs text-ink-300 mb-2">{{ t('concept.outgoing') }} ({{ outgoingEdges.length }})</div>
|
|
641
644
|
<div class="space-y-1 max-h-64 overflow-y-auto">
|
|
642
645
|
<button
|
|
643
646
|
v-for="edge in outgoingEdges"
|
|
@@ -654,7 +657,7 @@ const nonVerbalReps = computed(() => {
|
|
|
654
657
|
</div>
|
|
655
658
|
</div>
|
|
656
659
|
<div v-if="incomingEdges.length" class="mt-3 pt-3 border-t border-ink-100/60">
|
|
657
|
-
<div class="text-xs text-ink-300 mb-2">
|
|
660
|
+
<div class="text-xs text-ink-300 mb-2">{{ t('concept.incoming') }} ({{ incomingEdges.length }})</div>
|
|
658
661
|
<div class="space-y-1 max-h-48 overflow-y-auto">
|
|
659
662
|
<button
|
|
660
663
|
v-for="edge in incomingEdges"
|
|
@@ -673,7 +676,7 @@ const nonVerbalReps = computed(() => {
|
|
|
673
676
|
|
|
674
677
|
<!-- Domains -->
|
|
675
678
|
<div v-if="conceptDomains.length" class="card p-5">
|
|
676
|
-
<div class="section-label">
|
|
679
|
+
<div class="section-label">{{ t('concept.domains') }}</div>
|
|
677
680
|
<div class="space-y-1 mt-3">
|
|
678
681
|
<div v-for="domain in conceptDomains" :key="domain.slug" class="flex items-center gap-1.5 text-sm">
|
|
679
682
|
<span class="w-2 h-1.5 rounded inline-block flex-shrink-0" style="background: #8b5cf6;"></span>
|
|
@@ -688,7 +691,7 @@ const nonVerbalReps = computed(() => {
|
|
|
688
691
|
|
|
689
692
|
<!-- Tags -->
|
|
690
693
|
<div v-if="conceptTags.length" class="card p-5">
|
|
691
|
-
<div class="section-label">
|
|
694
|
+
<div class="section-label">{{ t('concept.tags') }}</div>
|
|
692
695
|
<div class="flex flex-wrap gap-1.5 mt-3">
|
|
693
696
|
<span v-for="tag in conceptTags" :key="tag" class="badge badge-gray text-[10px]">{{ tag }}</span>
|
|
694
697
|
</div>
|
|
@@ -696,7 +699,7 @@ const nonVerbalReps = computed(() => {
|
|
|
696
699
|
|
|
697
700
|
<!-- Managed concept dates -->
|
|
698
701
|
<div v-if="conceptDates.length" class="card p-5">
|
|
699
|
-
<div class="section-label">
|
|
702
|
+
<div class="section-label">{{ t('concept.lifecycleDates') }}</div>
|
|
700
703
|
<dl class="mt-3 space-y-1.5 text-xs">
|
|
701
704
|
<div v-for="(d, i) in conceptDates" :key="i" class="flex gap-2">
|
|
702
705
|
<dt class="text-ink-300 min-w-[70px]">{{ d.type }}</dt>
|
|
@@ -707,7 +710,7 @@ const nonVerbalReps = computed(() => {
|
|
|
707
710
|
|
|
708
711
|
<!-- Managed concept sources -->
|
|
709
712
|
<div v-if="conceptSources.length" class="card p-5">
|
|
710
|
-
<div class="section-label">
|
|
713
|
+
<div class="section-label">{{ t('concept.conceptSources') }}</div>
|
|
711
714
|
<div class="space-y-2 mt-3">
|
|
712
715
|
<div v-for="(src, i) in conceptSources" :key="i" class="text-xs">
|
|
713
716
|
<div class="flex items-center gap-1.5 flex-wrap mb-0.5">
|
|
@@ -757,7 +760,7 @@ const nonVerbalReps = computed(() => {
|
|
|
757
760
|
|
|
758
761
|
<!-- Metadata -->
|
|
759
762
|
<div class="card p-5">
|
|
760
|
-
<div class="section-label">
|
|
763
|
+
<div class="section-label">{{ t('concept.metadata') }}</div>
|
|
761
764
|
<dl class="space-y-2 text-xs mt-3">
|
|
762
765
|
<div v-if="managedStatus">
|
|
763
766
|
<dt class="text-ink-300">Status</dt>
|
|
@@ -4,6 +4,7 @@ import type { GraphNode, GraphEdge } from '../adapters/types';
|
|
|
4
4
|
import type { SimulationNodeDatum, SimulationLinkDatum } from 'd3';
|
|
5
5
|
import { useDsStyle } from '../utils/dataset-style';
|
|
6
6
|
import { useUiStore } from '../stores/ui';
|
|
7
|
+
import { useI18n } from '../i18n';
|
|
7
8
|
import {
|
|
8
9
|
forceSimulation,
|
|
9
10
|
forceLink,
|
|
@@ -53,6 +54,7 @@ watch(() => props.registers, (regs) => {
|
|
|
53
54
|
|
|
54
55
|
const { getColor } = useDsStyle();
|
|
55
56
|
const uiStore = useUiStore();
|
|
57
|
+
const { t } = useI18n();
|
|
56
58
|
|
|
57
59
|
const STUB_COLOR = '#b8b9cc'; // ink-200
|
|
58
60
|
const HIGHLIGHT_COLOR = '#1a1b2e'; // ink-800
|
|
@@ -466,11 +468,11 @@ function selectedNodeColor(): string {
|
|
|
466
468
|
<div class="bg-surface-raised/95 backdrop-blur rounded-xl border border-ink-100/60 overflow-hidden" style="box-shadow: 0 4px 12px rgba(26, 27, 46, 0.08);">
|
|
467
469
|
<button
|
|
468
470
|
@click="panelOpen = !panelOpen"
|
|
469
|
-
:aria-label="panelOpen ? '
|
|
471
|
+
:aria-label="panelOpen ? t('graph.collapseControls') : t('graph.expandControls')"
|
|
470
472
|
class="w-full px-4 py-2.5 flex items-center justify-between hover:bg-ink-50/50 transition-colors"
|
|
471
473
|
>
|
|
472
474
|
<span class="text-xs font-semibold text-ink-600 tracking-wide">
|
|
473
|
-
{{ nodeCount.toLocaleString() }} nodes · {{ edgeCount.toLocaleString() }} edges
|
|
475
|
+
{{ nodeCount.toLocaleString() }} {{ t('graph.nodes') }} · {{ edgeCount.toLocaleString() }} {{ t('graph.edges') }}
|
|
474
476
|
</span>
|
|
475
477
|
<svg class="w-3.5 h-3.5 text-ink-300 transition-transform" :class="panelOpen ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
476
478
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
@@ -481,9 +483,9 @@ function selectedNodeColor(): string {
|
|
|
481
483
|
<!-- Dataset toggles -->
|
|
482
484
|
<div class="mt-3 space-y-2">
|
|
483
485
|
<div class="flex items-center gap-2 mb-3">
|
|
484
|
-
<button @click="toggleAll(true)" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">
|
|
486
|
+
<button @click="toggleAll(true)" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">{{ t('graph.all') }}</button>
|
|
485
487
|
<span class="text-ink-200 text-xs">|</span>
|
|
486
|
-
<button @click="toggleAll(false)" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">
|
|
488
|
+
<button @click="toggleAll(false)" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">{{ t('graph.none') }}</button>
|
|
487
489
|
</div>
|
|
488
490
|
<div
|
|
489
491
|
v-for="reg in registers"
|
|
@@ -515,32 +517,32 @@ function selectedNodeColor(): string {
|
|
|
515
517
|
|
|
516
518
|
<!-- Actions -->
|
|
517
519
|
<div v-if="isCapped" class="text-[10px] text-amber-600 mt-2 leading-relaxed">
|
|
518
|
-
|
|
520
|
+
{{ t('graph.renderingWarning', { max: MAX_RENDER_NODES.toLocaleString(), total: nodeCount.toLocaleString() }) }}
|
|
519
521
|
</div>
|
|
520
522
|
|
|
521
523
|
<div v-if="nodeCount > 0" class="flex gap-4 mt-3 pt-3 border-t border-ink-100/40">
|
|
522
|
-
<button @click="resetZoom" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">
|
|
523
|
-
<button @click="rebuildGraph" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">
|
|
524
|
+
<button @click="resetZoom" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">{{ t('graph.resetZoom') }}</button>
|
|
525
|
+
<button @click="rebuildGraph" class="text-[10px] font-semibold text-ink-500 hover:text-ink-700 uppercase tracking-wide transition-colors">{{ t('graph.reLayout') }}</button>
|
|
524
526
|
</div>
|
|
525
527
|
|
|
526
528
|
<div v-if="nodeCount > 0" class="mt-3 pt-3 border-t border-ink-100/40">
|
|
527
|
-
<div class="text-[10px] font-semibold text-ink-400 uppercase tracking-wide mb-2">
|
|
529
|
+
<div class="text-[10px] font-semibold text-ink-400 uppercase tracking-wide mb-2">{{ t('graph.nodeLabels') }}</div>
|
|
528
530
|
<div class="flex gap-1">
|
|
529
531
|
<button
|
|
530
532
|
@click="labelMode = 'designation'; rebuildGraph()"
|
|
531
533
|
class="text-[10px] px-2 py-1 rounded font-medium transition-colors"
|
|
532
534
|
:class="labelMode === 'designation' ? 'bg-ink-800 text-white' : 'text-ink-500 hover:bg-ink-50'"
|
|
533
|
-
>
|
|
535
|
+
>{{ t('graph.designation') }}</button>
|
|
534
536
|
<button
|
|
535
537
|
@click="labelMode = 'identifier'; rebuildGraph()"
|
|
536
538
|
class="text-[10px] px-2 py-1 rounded font-medium transition-colors"
|
|
537
539
|
:class="labelMode === 'identifier' ? 'bg-ink-800 text-white' : 'text-ink-500 hover:bg-ink-50'"
|
|
538
|
-
>
|
|
540
|
+
>{{ t('graph.identifier') }}</button>
|
|
539
541
|
</div>
|
|
540
542
|
</div>
|
|
541
543
|
|
|
542
544
|
<div v-if="nodeCount === 0" class="text-xs text-ink-300 mt-3 leading-relaxed">
|
|
543
|
-
{{ props.edges.length > 0 ? '
|
|
545
|
+
{{ props.edges.length > 0 ? t('graph.enableDatasets') : t('graph.browseToPopulate') }}
|
|
544
546
|
</div>
|
|
545
547
|
</div>
|
|
546
548
|
</div>
|
|
@@ -549,7 +551,7 @@ function selectedNodeColor(): string {
|
|
|
549
551
|
<!-- Legend -->
|
|
550
552
|
<div v-if="nodeCount > 0" class="absolute top-4 right-4 z-10 bg-surface-raised/90 backdrop-blur rounded-lg px-3 py-2.5 border border-ink-100/60 text-xs" style="box-shadow: 0 2px 6px rgba(26, 27, 46, 0.04);">
|
|
551
553
|
<div v-if="registers.length > 1">
|
|
552
|
-
<div class="font-semibold text-ink-400 text-[10px] uppercase tracking-wide mb-2">
|
|
554
|
+
<div class="font-semibold text-ink-400 text-[10px] uppercase tracking-wide mb-2">{{ t('graph.datasets') }}</div>
|
|
553
555
|
<div v-for="reg in registers" :key="reg.id" class="flex items-center gap-2 mb-1.5 last:mb-0">
|
|
554
556
|
<span
|
|
555
557
|
class="w-2.5 h-2.5 rounded-full inline-block flex-shrink-0"
|
|
@@ -560,11 +562,11 @@ function selectedNodeColor(): string {
|
|
|
560
562
|
</div>
|
|
561
563
|
<div class="flex items-center gap-2 mt-2 pt-2 border-t border-ink-100/40">
|
|
562
564
|
<span class="w-2 h-2 rounded-full inline-block" :style="{ backgroundColor: STUB_COLOR }"></span>
|
|
563
|
-
<span class="text-ink-300">
|
|
565
|
+
<span class="text-ink-300">{{ t('graph.stubLabel') }}</span>
|
|
564
566
|
</div>
|
|
565
567
|
<div class="flex items-center gap-2 mt-2 pt-2 border-t border-ink-100/40">
|
|
566
568
|
<span class="w-4 h-2 rounded inline-block flex-shrink-0" style="background: #ede9fe; border: 1px solid #8b5cf6;"></span>
|
|
567
|
-
<span class="text-ink-300">
|
|
569
|
+
<span class="text-ink-300">{{ t('graph.domainLabel') }}</span>
|
|
568
570
|
</div>
|
|
569
571
|
</div>
|
|
570
572
|
|
|
@@ -590,7 +592,7 @@ function selectedNodeColor(): string {
|
|
|
590
592
|
></span>
|
|
591
593
|
<span class="text-[10px] text-ink-400 uppercase tracking-wide">
|
|
592
594
|
{{ registerTitle(selectedNode.register) }} ·
|
|
593
|
-
{{ selectedNode.loaded ? '
|
|
595
|
+
{{ selectedNode.loaded ? t('graph.loadedStatus') : t('graph.stubStatus') }}
|
|
594
596
|
</span>
|
|
595
597
|
</div>
|
|
596
598
|
</div>
|
|
@@ -603,7 +605,7 @@ function selectedNodeColor(): string {
|
|
|
603
605
|
:to="{ name: 'concept', params: { registerId: selectedNode.register, conceptId: selectedNode.conceptId } }"
|
|
604
606
|
class="btn-primary text-xs mt-4 inline-block"
|
|
605
607
|
>
|
|
606
|
-
|
|
608
|
+
{{ t('graph.viewConcept') }}
|
|
607
609
|
</router-link>
|
|
608
610
|
</div>
|
|
609
611
|
</div>
|