@glossarist/concept-browser 0.1.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/README.md +319 -0
- package/cli/index.mjs +119 -0
- package/env.d.ts +7 -0
- package/index.html +16 -0
- package/package.json +78 -0
- package/postcss.config.js +6 -0
- package/scripts/build-edges.js +112 -0
- package/scripts/fetch-datasets.mjs +195 -0
- package/scripts/generate-404.js +15 -0
- package/scripts/generate-data.mjs +606 -0
- package/scripts/load-site-config.mjs +56 -0
- package/src/App.vue +98 -0
- package/src/__tests__/data-integration.test.ts +135 -0
- package/src/__tests__/data-integrity.test.ts +101 -0
- package/src/__tests__/dataset-adapter.test.ts +336 -0
- package/src/__tests__/dataset-style.test.ts +37 -0
- package/src/__tests__/graph.test.ts +187 -0
- package/src/__tests__/lang.test.ts +29 -0
- package/src/__tests__/math.test.ts +113 -0
- package/src/__tests__/reference-resolver.test.ts +122 -0
- package/src/__tests__/site-config.test.ts +52 -0
- package/src/__tests__/uri-router.test.ts +76 -0
- package/src/adapters/DatasetAdapter.ts +270 -0
- package/src/adapters/ReferenceResolver.ts +95 -0
- package/src/adapters/UriRouter.ts +41 -0
- package/src/adapters/factory.ts +78 -0
- package/src/adapters/types.ts +162 -0
- package/src/components/AppHeader.vue +99 -0
- package/src/components/AppSidebar.vue +133 -0
- package/src/components/ConceptCard.vue +65 -0
- package/src/components/ConceptDetail.vue +540 -0
- package/src/components/ConceptTimeline.vue +410 -0
- package/src/components/FormatDownloads.vue +46 -0
- package/src/components/GraphPanel.vue +499 -0
- package/src/components/LanguageDetail.vue +211 -0
- package/src/components/NavIcon.vue +20 -0
- package/src/components/SearchBar.vue +241 -0
- package/src/composables/use-dataset-loader.ts +27 -0
- package/src/config/types.ts +130 -0
- package/src/config/use-site-config.ts +144 -0
- package/src/graph/GraphEngine.ts +137 -0
- package/src/graph/index.ts +1 -0
- package/src/main.ts +11 -0
- package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/src/router/index.ts +43 -0
- package/src/router/page-routes.ts +35 -0
- package/src/stores/ui.ts +59 -0
- package/src/stores/vocabulary.ts +309 -0
- package/src/style.css +314 -0
- package/src/utils/asciidoc-lite.ts +123 -0
- package/src/utils/concept-formats.ts +157 -0
- package/src/utils/dataset-style.ts +54 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/lang.ts +32 -0
- package/src/utils/math.ts +100 -0
- package/src/views/AboutView.vue +122 -0
- package/src/views/ConceptView.vue +119 -0
- package/src/views/ContributorsView.vue +110 -0
- package/src/views/DatasetView.vue +249 -0
- package/src/views/GraphView.vue +65 -0
- package/src/views/HomeView.vue +168 -0
- package/src/views/NewsView.vue +146 -0
- package/src/views/ResolveView.vue +63 -0
- package/src/views/SearchView.vue +33 -0
- package/src/views/StatsView.vue +121 -0
- package/tailwind.config.js +43 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +27 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ConceptDocument, LocalizedConcept, GraphEdge } from '../adapters/types';
|
|
3
|
+
import type { Manifest } from '../adapters/types';
|
|
4
|
+
import { computed, ref, nextTick } from 'vue';
|
|
5
|
+
import { langName, langLabel } from '../utils/lang';
|
|
6
|
+
import { renderMath, cleanContent } from '../utils/math';
|
|
7
|
+
import type { XrefResolver } from '../utils/math';
|
|
8
|
+
import { useRouter } from 'vue-router';
|
|
9
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
10
|
+
import { useDsStyle } from '../utils/dataset-style';
|
|
11
|
+
import { getFactory } from '../adapters/factory';
|
|
12
|
+
import ConceptTimeline from './ConceptTimeline.vue';
|
|
13
|
+
import FormatDownloads from './FormatDownloads.vue';
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
concept: ConceptDocument;
|
|
17
|
+
manifest: Manifest;
|
|
18
|
+
edges: GraphEdge[];
|
|
19
|
+
registerId: string;
|
|
20
|
+
adjacent: { prev: string | null; next: string | null };
|
|
21
|
+
}>();
|
|
22
|
+
|
|
23
|
+
const router = useRouter();
|
|
24
|
+
const store = useVocabularyStore();
|
|
25
|
+
const { getColor } = useDsStyle();
|
|
26
|
+
const factory = getFactory();
|
|
27
|
+
|
|
28
|
+
const activeTab = ref<'definition' | 'history'>('definition');
|
|
29
|
+
const activeHistoryLang = ref('eng');
|
|
30
|
+
|
|
31
|
+
const conceptId = computed(() => props.concept['gl:identifier']);
|
|
32
|
+
|
|
33
|
+
const conceptPosition = computed(() => {
|
|
34
|
+
const adapter = store.datasets.get(props.registerId);
|
|
35
|
+
if (!adapter?.index) return null;
|
|
36
|
+
const idx = adapter.getConceptPosition(conceptId.value);
|
|
37
|
+
if (idx < 0) return null;
|
|
38
|
+
return { index: idx + 1, total: adapter.getConceptCount() };
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const uriCopied = ref(false);
|
|
42
|
+
function copyUri() {
|
|
43
|
+
navigator.clipboard.writeText(props.concept['@id']).then(() => {
|
|
44
|
+
uriCopied.value = true;
|
|
45
|
+
setTimeout(() => { uriCopied.value = false; }, 2000);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const languages = computed(() => {
|
|
50
|
+
const order = props.manifest.languageOrder;
|
|
51
|
+
const keys = Object.keys(props.concept['gl:localizedConcept'] || {});
|
|
52
|
+
if (!order) {
|
|
53
|
+
return keys.sort((a, b) => {
|
|
54
|
+
if (a === 'eng') return -1;
|
|
55
|
+
if (b === 'eng') return 1;
|
|
56
|
+
return a.localeCompare(b);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const orderIndex = new Map(order.map((lang, i) => [lang, i]));
|
|
60
|
+
return keys.sort((a, b) => {
|
|
61
|
+
const ai = orderIndex.get(a) ?? order.length;
|
|
62
|
+
const bi = orderIndex.get(b) ?? order.length;
|
|
63
|
+
if (ai !== bi) return ai - bi;
|
|
64
|
+
return a.localeCompare(b);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const engConcept = computed((): LocalizedConcept | null => {
|
|
69
|
+
return props.concept['gl:localizedConcept']?.['eng'] ?? null;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const primaryTerm = computed(() => {
|
|
73
|
+
const eng = engConcept.value;
|
|
74
|
+
if (!eng?.['gl:designation']?.length) return conceptId.value;
|
|
75
|
+
const desigs = eng['gl:designation'];
|
|
76
|
+
const preferredExpr = desigs.find(d => d['gl:normativeStatus'] === 'preferred' && d['@type'] === 'gl:Expression');
|
|
77
|
+
if (preferredExpr) return preferredExpr['gl:term'];
|
|
78
|
+
const preferred = desigs.find(d => d['gl:normativeStatus'] === 'preferred');
|
|
79
|
+
return preferred?.['gl:term'] ?? desigs[0]?.['gl:term'] ?? conceptId.value;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Cross-reference resolver: generates clickable links for inline refs
|
|
83
|
+
function escapeAttr(s: string) {
|
|
84
|
+
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const xrefResolver: XrefResolver = (uri, term) => {
|
|
88
|
+
const resolution = factory.resolve(uri, props.registerId);
|
|
89
|
+
if (resolution.type === 'internal') {
|
|
90
|
+
return `<a href="#" class="xref-link" data-register="${escapeAttr(resolution.registerId)}" data-concept="${escapeAttr(resolution.conceptId)}">${escapeAttr(term)}</a>`;
|
|
91
|
+
}
|
|
92
|
+
if (resolution.type === 'site') {
|
|
93
|
+
return `<a href="${escapeAttr(resolution.baseUrl)}/resolve/${escapeAttr(encodeURIComponent(uri))}" target="_blank" rel="noopener" class="xref-link xref-external">${escapeAttr(term)}</a>`;
|
|
94
|
+
}
|
|
95
|
+
if (resolution.type === 'url') {
|
|
96
|
+
return `<a href="${escapeAttr(resolution.url)}" target="_blank" rel="noopener" class="xref-link xref-external">${escapeAttr(term)}</a>`;
|
|
97
|
+
}
|
|
98
|
+
return escapeAttr(term);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Handle clicks on cross-reference links via event delegation
|
|
102
|
+
function handleContentClick(e: MouseEvent) {
|
|
103
|
+
const target = (e.target as HTMLElement).closest('.xref-link') as HTMLElement | null;
|
|
104
|
+
if (!target) return;
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
const registerId = target.dataset.register;
|
|
107
|
+
const conceptId = target.dataset.concept;
|
|
108
|
+
if (registerId && conceptId) {
|
|
109
|
+
store.viewConcept(registerId, conceptId);
|
|
110
|
+
router.push({ name: 'concept', params: { registerId, conceptId } });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Pre-computed content for all languages (sorted eng first)
|
|
115
|
+
interface LangContent {
|
|
116
|
+
lang: string;
|
|
117
|
+
definition: string;
|
|
118
|
+
notes: string[];
|
|
119
|
+
examples: string[];
|
|
120
|
+
sources: any[];
|
|
121
|
+
designations: any[];
|
|
122
|
+
entryStatus: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const allLangContent = computed(() => {
|
|
126
|
+
const result: LangContent[] = [];
|
|
127
|
+
for (const lang of languages.value) {
|
|
128
|
+
const lc = props.concept['gl:localizedConcept']?.[lang];
|
|
129
|
+
if (!lc) continue;
|
|
130
|
+
|
|
131
|
+
const defs = lc['gl:definition'];
|
|
132
|
+
const definition = defs?.length
|
|
133
|
+
? defs.map(d => d['gl:content']).filter(Boolean).join('\n\n')
|
|
134
|
+
: '';
|
|
135
|
+
|
|
136
|
+
result.push({
|
|
137
|
+
lang,
|
|
138
|
+
definition,
|
|
139
|
+
notes: lc['gl:notes']?.map((n: any) => n['gl:content']).filter(Boolean) ?? [],
|
|
140
|
+
examples: lc['gl:examples']?.map((e: any) => e['gl:content']).filter(Boolean) ?? [],
|
|
141
|
+
sources: lc['gl:source'] ?? [],
|
|
142
|
+
designations: lc['gl:designation'] ?? [],
|
|
143
|
+
entryStatus: lc['gl:entryStatus'] ?? '',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Collapsible language sections — all expanded by default
|
|
150
|
+
const collapsedLangs = ref(new Set<string>());
|
|
151
|
+
|
|
152
|
+
function hasContent(lc: LangContent): boolean {
|
|
153
|
+
return !!(lc.definition || lc.notes.length || lc.examples.length || lc.sources.length);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const allCollapsed = computed(() => collapsedLangs.value.size === allLangContent.value.length);
|
|
157
|
+
|
|
158
|
+
function toggleLang(lang: string) {
|
|
159
|
+
const s = new Set(collapsedLangs.value);
|
|
160
|
+
if (s.has(lang)) s.delete(lang); else s.add(lang);
|
|
161
|
+
collapsedLangs.value = s;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function toggleAll() {
|
|
165
|
+
if (allCollapsed.value) {
|
|
166
|
+
collapsedLangs.value = new Set();
|
|
167
|
+
} else {
|
|
168
|
+
collapsedLangs.value = new Set(allLangContent.value.map(lc => lc.lang));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function scrollToLang(lang: string) {
|
|
173
|
+
// Expand if collapsed
|
|
174
|
+
if (collapsedLangs.value.has(lang)) {
|
|
175
|
+
const s = new Set(collapsedLangs.value);
|
|
176
|
+
s.delete(lang);
|
|
177
|
+
collapsedLangs.value = s;
|
|
178
|
+
}
|
|
179
|
+
// Switch to definition tab if needed
|
|
180
|
+
activeTab.value = 'definition';
|
|
181
|
+
nextTick(() => {
|
|
182
|
+
document.getElementById(`lang-${lang}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const outgoingEdges = computed(() => props.edges.filter(e => e.source === props.concept['@id']));
|
|
187
|
+
const incomingEdges = computed(() => props.edges.filter(e => e.target === props.concept['@id']));
|
|
188
|
+
|
|
189
|
+
async function navigateEdge(edge: GraphEdge) {
|
|
190
|
+
const uri = edge.source === props.concept['@id'] ? edge.target : edge.source;
|
|
191
|
+
const resolution = factory.resolve(uri);
|
|
192
|
+
|
|
193
|
+
if (resolution.type === 'internal') {
|
|
194
|
+
await store.viewConcept(resolution.registerId, resolution.conceptId);
|
|
195
|
+
router.push({ name: 'concept', params: { registerId: resolution.registerId, conceptId: resolution.conceptId } });
|
|
196
|
+
} else if (resolution.type === 'site') {
|
|
197
|
+
window.open(`${resolution.baseUrl}/resolve/${encodeURIComponent(uri)}`, '_blank', 'noopener');
|
|
198
|
+
} else if (resolution.type === 'url') {
|
|
199
|
+
window.open(resolution.url, '_blank', 'noopener');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function getTermForLang(lang: string): string {
|
|
204
|
+
const lc = props.concept['gl:localizedConcept']?.[lang];
|
|
205
|
+
if (!lc?.['gl:designation']?.length) return '\u2014';
|
|
206
|
+
const desigs = lc['gl:designation'];
|
|
207
|
+
const preferredExpr = desigs.find(d => d['gl:normativeStatus'] === 'preferred' && d['@type'] === 'gl:Expression');
|
|
208
|
+
if (preferredExpr) return preferredExpr['gl:term'];
|
|
209
|
+
const preferred = desigs.find(d => d['gl:normativeStatus'] === 'preferred');
|
|
210
|
+
return preferred?.['gl:term'] ?? desigs[0]?.['gl:term'] ?? '\u2014';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function getDesignationsForLang(lang: string) {
|
|
214
|
+
const lc = props.concept['gl:localizedConcept']?.[lang];
|
|
215
|
+
return lc?.['gl:designation'] ?? [];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function orderedDesignations(lang: string) {
|
|
219
|
+
const desigs = getDesignationsForLang(lang);
|
|
220
|
+
const preferred = desigs.filter(d => d['gl:normativeStatus'] === 'preferred');
|
|
221
|
+
const admitted = desigs.filter(d => d['gl:normativeStatus'] === 'admitted' || d['gl:normativeStatus'] === 'deprecated');
|
|
222
|
+
const rest = desigs.filter(d => d['gl:normativeStatus'] !== 'preferred' && d['gl:normativeStatus'] !== 'admitted' && d['gl:normativeStatus'] !== 'deprecated');
|
|
223
|
+
return [...preferred, ...admitted, ...rest];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function hasDefinition(lang: string): boolean {
|
|
227
|
+
const lc = props.concept['gl:localizedConcept']?.[lang];
|
|
228
|
+
if (!lc) return false;
|
|
229
|
+
return lc['gl:definition']?.some((d: any) => d['gl:content']) ?? false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function designationTypeLabel(type: string): string {
|
|
233
|
+
const labels: Record<string, string> = {
|
|
234
|
+
'gl:Expression': 'Expression',
|
|
235
|
+
'gl:Symbol': 'Symbol',
|
|
236
|
+
'gl:Abbreviation': 'Abbreviation',
|
|
237
|
+
'gl:GraphicalSymbol': 'Graphical',
|
|
238
|
+
};
|
|
239
|
+
return labels[type] ?? type;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function designationTypeColor(type: string): string {
|
|
243
|
+
if (type === 'gl:Symbol') return 'badge-purple';
|
|
244
|
+
if (type === 'gl:Abbreviation') return 'badge-yellow';
|
|
245
|
+
return 'badge-blue';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function entryStatusColor(status: string): string {
|
|
249
|
+
if (status === 'valid' || status === 'Standard') return 'badge-green';
|
|
250
|
+
if (status === 'superseded') return 'bg-red-50 text-red-700';
|
|
251
|
+
if (status === 'withdrawn') return 'bg-red-100 text-red-800';
|
|
252
|
+
if (status === 'draft') return 'badge-yellow';
|
|
253
|
+
return 'badge-gray';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function goAdjacent(id: string) {
|
|
257
|
+
router.push({ name: 'concept', params: { registerId: props.registerId, conceptId: id } });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function plainTruncate(html: string, max: number = 120): string {
|
|
261
|
+
const text = cleanContent(html).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
262
|
+
return text.length <= max ? text : text.slice(0, max).trimEnd() + '\u2026';
|
|
263
|
+
}
|
|
264
|
+
</script>
|
|
265
|
+
|
|
266
|
+
<template>
|
|
267
|
+
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
268
|
+
<!-- Header -->
|
|
269
|
+
<div class="mb-6">
|
|
270
|
+
<!-- Breadcrumb + nav row -->
|
|
271
|
+
<div class="flex items-start gap-2 mb-3">
|
|
272
|
+
<nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 min-w-0 flex-1 flex-wrap">
|
|
273
|
+
<router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors whitespace-nowrap">Home</router-link>
|
|
274
|
+
<span class="text-ink-200">/</span>
|
|
275
|
+
<router-link :to="{ name: 'dataset', params: { registerId: manifest.id }}" class="hover:text-ink-700 transition-colors truncate max-w-[180px]">
|
|
276
|
+
{{ manifest.title }}
|
|
277
|
+
</router-link>
|
|
278
|
+
<span class="text-ink-200">/</span>
|
|
279
|
+
<span class="text-ink-600 font-mono text-xs">{{ conceptId }}</span>
|
|
280
|
+
<span v-if="conceptPosition" class="text-[10px] text-ink-300 tabular-nums ml-1 whitespace-nowrap">({{ conceptPosition.index }} of {{ conceptPosition.total.toLocaleString() }})</span>
|
|
281
|
+
</nav>
|
|
282
|
+
<!-- Prev/Next navigation -->
|
|
283
|
+
<div v-if="adjacent.prev || adjacent.next" class="flex items-center gap-1 flex-shrink-0">
|
|
284
|
+
<button
|
|
285
|
+
v-if="adjacent.prev"
|
|
286
|
+
@click="goAdjacent(adjacent.prev)"
|
|
287
|
+
class="p-1.5 rounded-md text-ink-300 hover:text-ink-600 hover:bg-ink-50 transition-colors"
|
|
288
|
+
title="Previous concept"
|
|
289
|
+
>
|
|
290
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
|
291
|
+
</button>
|
|
292
|
+
<button
|
|
293
|
+
v-if="adjacent.next"
|
|
294
|
+
@click="goAdjacent(adjacent.next)"
|
|
295
|
+
class="p-1.5 rounded-md text-ink-300 hover:text-ink-600 hover:bg-ink-50 transition-colors"
|
|
296
|
+
title="Next concept"
|
|
297
|
+
>
|
|
298
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
|
299
|
+
</button>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
<h1 class="font-serif text-2xl sm:text-3xl text-ink-800 leading-snug mb-3" v-html="renderMath(cleanContent(primaryTerm))"></h1>
|
|
303
|
+
<div class="flex flex-wrap gap-2">
|
|
304
|
+
<span class="badge badge-blue font-mono">{{ conceptId }}</span>
|
|
305
|
+
<span class="badge" :class="entryStatusColor(engConcept?.['gl:entryStatus'] ?? '')" v-if="engConcept?.['gl:entryStatus']">
|
|
306
|
+
{{ engConcept['gl:entryStatus'] }}
|
|
307
|
+
</span>
|
|
308
|
+
<span class="badge badge-gray" v-if="manifest.owner">{{ manifest.owner }}</span>
|
|
309
|
+
<span class="badge badge-purple">{{ languages.length }} languages</span>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
<!-- Tab navigation -->
|
|
314
|
+
<div role="tablist" class="flex border-b border-ink-100/60 mb-6">
|
|
315
|
+
<button
|
|
316
|
+
role="tab"
|
|
317
|
+
:aria-selected="activeTab === 'definition'"
|
|
318
|
+
@click="activeTab = 'definition'"
|
|
319
|
+
:class="activeTab === 'definition' ? 'border-ink-800 text-ink-800' : 'border-transparent text-ink-400 hover:text-ink-600'"
|
|
320
|
+
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px"
|
|
321
|
+
>
|
|
322
|
+
Definition
|
|
323
|
+
</button>
|
|
324
|
+
<button
|
|
325
|
+
role="tab"
|
|
326
|
+
:aria-selected="activeTab === 'history'"
|
|
327
|
+
@click="activeTab = 'history'"
|
|
328
|
+
:class="activeTab === 'history' ? 'border-ink-800 text-ink-800' : 'border-transparent text-ink-400 hover:text-ink-600'"
|
|
329
|
+
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px"
|
|
330
|
+
>
|
|
331
|
+
History
|
|
332
|
+
</button>
|
|
333
|
+
<!-- Expand/Collapse all toggle (definition tab only) -->
|
|
334
|
+
<button
|
|
335
|
+
v-if="activeTab === 'definition'"
|
|
336
|
+
@click="toggleAll"
|
|
337
|
+
class="ml-auto px-3 py-2 text-xs text-ink-400 hover:text-ink-600 transition-colors"
|
|
338
|
+
>
|
|
339
|
+
{{ allCollapsed ? 'Expand all' : 'Collapse all' }}
|
|
340
|
+
<span class="text-ink-300 ml-0.5">({{ languages.length }})</span>
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
<!-- Tab: Definition -->
|
|
345
|
+
<div v-if="activeTab === 'definition'" role="tabpanel">
|
|
346
|
+
<div class="lg:flex lg:gap-8">
|
|
347
|
+
<!-- Left: all language content -->
|
|
348
|
+
<div class="flex-1 min-w-0 space-y-2" @click="handleContentClick">
|
|
349
|
+
<!-- Per-language collapsible blocks -->
|
|
350
|
+
<div v-for="lc in allLangContent" :key="lc.lang" :id="`lang-${lc.lang}`" class="border border-ink-100/80 rounded-lg overflow-hidden">
|
|
351
|
+
<!-- Collapsible header -->
|
|
352
|
+
<button
|
|
353
|
+
v-if="hasContent(lc)"
|
|
354
|
+
@click="toggleLang(lc.lang)"
|
|
355
|
+
class="w-full flex items-center gap-2.5 px-3 sm:px-4 py-3 text-left hover:bg-ink-50/50 transition-colors"
|
|
356
|
+
>
|
|
357
|
+
<svg
|
|
358
|
+
class="w-3.5 h-3.5 text-ink-300 transition-transform duration-200 flex-shrink-0"
|
|
359
|
+
:class="collapsedLangs.has(lc.lang) ? '' : 'rotate-90'"
|
|
360
|
+
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
361
|
+
>
|
|
362
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
|
363
|
+
</svg>
|
|
364
|
+
<span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(lc.lang) }}</span>
|
|
365
|
+
<span class="font-medium text-ink-800 text-sm" v-html="renderMath(getTermForLang(lc.lang))"></span>
|
|
366
|
+
<span v-if="lc.entryStatus" class="badge text-[10px] ml-auto" :class="entryStatusColor(lc.entryStatus)">{{ lc.entryStatus }}</span>
|
|
367
|
+
</button>
|
|
368
|
+
<!-- Non-collapsible header (designation only) -->
|
|
369
|
+
<div v-else class="w-full flex items-center gap-2.5 px-3 sm:px-4 py-3">
|
|
370
|
+
<span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(lc.lang) }}</span>
|
|
371
|
+
<span class="font-medium text-ink-800 text-sm" v-html="renderMath(getTermForLang(lc.lang))"></span>
|
|
372
|
+
<span class="text-xs text-ink-200 ml-2">No definition provided</span>
|
|
373
|
+
<span v-if="lc.entryStatus" class="badge text-[10px] ml-auto" :class="entryStatusColor(lc.entryStatus)">{{ lc.entryStatus }}</span>
|
|
374
|
+
</div>
|
|
375
|
+
<!-- Collapsed preview -->
|
|
376
|
+
<div v-if="hasContent(lc) && collapsedLangs.has(lc.lang) && lc.definition" class="px-3 sm:px-4 pb-3 -mt-0.5">
|
|
377
|
+
<p class="text-xs text-ink-300 leading-relaxed pl-[22px]">{{ plainTruncate(lc.definition) }}</p>
|
|
378
|
+
</div>
|
|
379
|
+
|
|
380
|
+
<!-- Expandable content -->
|
|
381
|
+
<div v-if="hasContent(lc)" v-show="!collapsedLangs.has(lc.lang)" class="lang-content px-3 sm:px-4 pb-4 space-y-3">
|
|
382
|
+
<!-- Designations -->
|
|
383
|
+
<div v-if="lc.designations.length > 1" class="space-y-1 pl-[22px]">
|
|
384
|
+
<div v-for="(d, i) in orderedDesignations(lc.lang)" :key="i" class="flex items-center gap-2 text-sm">
|
|
385
|
+
<span :class="d['gl:normativeStatus'] === 'preferred' ? 'font-bold text-ink-800' : 'font-normal text-ink-700'" v-html="renderMath(d['gl:term'])"></span>
|
|
386
|
+
<span class="badge text-[10px] flex-shrink-0" :class="designationTypeColor(d['@type'])">{{ designationTypeLabel(d['@type']) }}</span>
|
|
387
|
+
<span v-if="d['gl:normativeStatus'] && d['gl:normativeStatus'] !== 'preferred'" class="badge badge-yellow text-[10px] flex-shrink-0">{{ d['gl:normativeStatus'] }}</span>
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
|
|
391
|
+
<!-- Definition -->
|
|
392
|
+
<div v-if="lc.definition" class="p-4 rounded-lg bg-surface border-l-2" :style="{ borderLeftColor: getColor(manifest.id) }">
|
|
393
|
+
<div class="text-ink-800 leading-relaxed" v-html="renderMath(lc.definition, xrefResolver)"></div>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<!-- Notes -->
|
|
397
|
+
<div v-if="lc.notes.length" class="space-y-2">
|
|
398
|
+
<div v-for="(note, i) in lc.notes" :key="i" class="text-ink-600 text-sm leading-relaxed">
|
|
399
|
+
<span class="font-medium text-ink-400 text-xs uppercase tracking-wide">Note {{ i + 1 }}</span>
|
|
400
|
+
<div class="mt-1" v-html="renderMath(note, xrefResolver)"></div>
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
<!-- Examples -->
|
|
405
|
+
<div v-if="lc.examples.length" class="space-y-2">
|
|
406
|
+
<div v-for="(ex, i) in lc.examples" :key="i" class="text-ink-600 text-sm leading-relaxed">
|
|
407
|
+
<span class="font-medium text-ink-400 text-xs uppercase tracking-wide">Example {{ i + 1 }}</span>
|
|
408
|
+
<div class="mt-1" v-html="renderMath(ex, xrefResolver)"></div>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
|
|
412
|
+
<!-- Sources -->
|
|
413
|
+
<div v-if="lc.sources.length" class="space-y-2">
|
|
414
|
+
<div v-for="(src, i) in lc.sources" :key="i" class="text-sm">
|
|
415
|
+
<div class="flex items-center gap-1.5 flex-wrap mb-1">
|
|
416
|
+
<span v-if="src['gl:sourceType']" class="badge badge-blue text-[10px]">{{ src['gl:sourceType'] }}</span>
|
|
417
|
+
<span v-if="src['gl:sourceStatus']" class="badge badge-gray text-[10px]">{{ src['gl:sourceStatus'] }}</span>
|
|
418
|
+
</div>
|
|
419
|
+
<div class="text-ink-700">
|
|
420
|
+
<span v-if="src['gl:origin']?.['gl:ref']" class="font-medium">{{ src['gl:origin']['gl:ref'] }}</span>
|
|
421
|
+
<span v-if="src['gl:origin']?.['gl:clause']">, {{ src['gl:origin']['gl:clause'] }}</span>
|
|
422
|
+
<a v-if="src['gl:origin']?.['gl:link']" :href="src['gl:origin']['gl:link']" target="_blank" class="concept-link ml-1">[link]</a>
|
|
423
|
+
</div>
|
|
424
|
+
<div v-if="src['gl:modification']" class="text-xs text-ink-300 mt-1">{{ src['gl:modification'] }}</div>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
|
|
431
|
+
<!-- Right sidebar -->
|
|
432
|
+
<div class="w-full lg:w-64 flex-shrink-0 space-y-4 mt-6 lg:mt-0">
|
|
433
|
+
<!-- Relations -->
|
|
434
|
+
<div v-if="outgoingEdges.length || incomingEdges.length" class="card p-5">
|
|
435
|
+
<div class="section-label">Relations</div>
|
|
436
|
+
<div v-if="outgoingEdges.length" class="mt-3">
|
|
437
|
+
<div class="text-xs text-ink-300 mb-2">References ({{ outgoingEdges.length }})</div>
|
|
438
|
+
<div class="space-y-1 max-h-48 overflow-y-auto">
|
|
439
|
+
<button
|
|
440
|
+
v-for="edge in outgoingEdges"
|
|
441
|
+
:key="edge.target"
|
|
442
|
+
@click="navigateEdge(edge)"
|
|
443
|
+
class="text-sm concept-link block truncate w-full text-left"
|
|
444
|
+
>
|
|
445
|
+
{{ edge.label || edge.target.split('/').pop() }}
|
|
446
|
+
</button>
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
<div v-if="incomingEdges.length" class="mt-3 pt-3 border-t border-ink-100/60">
|
|
450
|
+
<div class="text-xs text-ink-300 mb-2">Referenced by ({{ incomingEdges.length }})</div>
|
|
451
|
+
<div class="space-y-1 max-h-48 overflow-y-auto">
|
|
452
|
+
<button
|
|
453
|
+
v-for="edge in incomingEdges"
|
|
454
|
+
:key="edge.source"
|
|
455
|
+
@click="navigateEdge(edge)"
|
|
456
|
+
class="text-sm concept-link block truncate w-full text-left"
|
|
457
|
+
>
|
|
458
|
+
{{ edge.label || edge.source.split('/').pop() }}
|
|
459
|
+
</button>
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
|
|
464
|
+
<!-- Language quick-jump -->
|
|
465
|
+
<div class="card p-5">
|
|
466
|
+
<div class="section-label">Languages ({{ languages.length }})</div>
|
|
467
|
+
<div class="space-y-1 mt-3 max-h-80 overflow-y-auto">
|
|
468
|
+
<button
|
|
469
|
+
v-for="lang in languages"
|
|
470
|
+
:key="lang"
|
|
471
|
+
@click="scrollToLang(lang)"
|
|
472
|
+
class="w-full text-left group rounded-md px-2 py-1.5 -mx-2 hover:bg-ink-50 transition-colors"
|
|
473
|
+
>
|
|
474
|
+
<div class="flex items-center gap-1.5">
|
|
475
|
+
<span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(lang) }}</span>
|
|
476
|
+
<span
|
|
477
|
+
class="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
|
478
|
+
:class="hasDefinition(lang) ? 'bg-emerald-400' : 'bg-ink-200'"
|
|
479
|
+
:title="hasDefinition(lang) ? 'Has definition' : 'Designation only'"
|
|
480
|
+
></span>
|
|
481
|
+
<span class="text-sm font-medium text-ink-800 group-hover:text-ink-900 transition-colors" v-html="renderMath(getTermForLang(lang))"></span>
|
|
482
|
+
</div>
|
|
483
|
+
<div v-if="getDesignationsForLang(lang).length > 1" class="ml-5 mt-0.5 flex flex-wrap gap-1">
|
|
484
|
+
<span
|
|
485
|
+
v-for="d in getDesignationsForLang(lang)"
|
|
486
|
+
:key="d['gl:term']"
|
|
487
|
+
:class="d['@type'] === 'gl:Symbol' ? 'badge-purple' : 'badge-gray'"
|
|
488
|
+
class="badge text-[10px]"
|
|
489
|
+
>
|
|
490
|
+
{{ d['gl:term'] }}
|
|
491
|
+
</span>
|
|
492
|
+
</div>
|
|
493
|
+
</button>
|
|
494
|
+
</div>
|
|
495
|
+
</div>
|
|
496
|
+
|
|
497
|
+
<!-- Metadata -->
|
|
498
|
+
<div class="card p-5">
|
|
499
|
+
<div class="section-label">Metadata</div>
|
|
500
|
+
<dl class="space-y-2 text-xs mt-3">
|
|
501
|
+
<div v-if="engConcept?.['gl:reviewDate']">
|
|
502
|
+
<dt class="text-ink-300">Review Date</dt>
|
|
503
|
+
<dd class="text-ink-700 mt-0.5">{{ engConcept['gl:reviewDate'].slice(0, 10) }}</dd>
|
|
504
|
+
</div>
|
|
505
|
+
<div v-if="engConcept?.['gl:reviewDecisionEvent']">
|
|
506
|
+
<dt class="text-ink-300">Decision</dt>
|
|
507
|
+
<dd class="text-ink-700 mt-0.5">{{ engConcept['gl:reviewDecisionEvent'] }}</dd>
|
|
508
|
+
</div>
|
|
509
|
+
<div>
|
|
510
|
+
<dt class="text-ink-300">URI</dt>
|
|
511
|
+
<dd class="font-mono text-ink-600 break-all mt-0.5 text-[11px] flex items-start gap-1.5">
|
|
512
|
+
<span class="break-all">{{ concept['@id'] }}</span>
|
|
513
|
+
<button @click="copyUri" class="flex-shrink-0 p-0.5 rounded text-ink-300 hover:text-ink-600 hover:bg-ink-50 transition-colors" :title="uriCopied ? 'Copied!' : 'Copy URI'" :aria-label="uriCopied ? 'URI copied' : 'Copy URI to clipboard'">
|
|
514
|
+
<svg v-if="!uriCopied" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10a2 2 0 01-2-2v-1m6 4v-3a2 2 0 00-2-2H8"/></svg>
|
|
515
|
+
<svg v-else class="w-3.5 h-3.5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
516
|
+
</button>
|
|
517
|
+
</dd>
|
|
518
|
+
</div>
|
|
519
|
+
</dl>
|
|
520
|
+
</div>
|
|
521
|
+
|
|
522
|
+
<FormatDownloads
|
|
523
|
+
:register-id="manifest.id"
|
|
524
|
+
:concept-id="conceptId"
|
|
525
|
+
:formats="manifest.availableFormats || []"
|
|
526
|
+
/>
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
|
|
531
|
+
<!-- Tab: History -->
|
|
532
|
+
<div v-if="activeTab === 'history'" role="tabpanel">
|
|
533
|
+
<ConceptTimeline
|
|
534
|
+
:localized-concepts="concept['gl:localizedConcept'] || {}"
|
|
535
|
+
:language-order="manifest.languageOrder"
|
|
536
|
+
v-model:active-lang="activeHistoryLang"
|
|
537
|
+
/>
|
|
538
|
+
</div>
|
|
539
|
+
</div>
|
|
540
|
+
</template>
|