@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,410 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { LocalizedConcept } from '../adapters/types';
|
|
3
|
+
import { computed } from 'vue';
|
|
4
|
+
import { langName, langLabel } from '../utils/lang';
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
localizedConcepts: Record<string, LocalizedConcept>;
|
|
8
|
+
activeLang: string;
|
|
9
|
+
languageOrder?: string[];
|
|
10
|
+
}>();
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits<{
|
|
13
|
+
(e: 'update:activeLang', lang: string): void;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
interface TimelineEntry {
|
|
17
|
+
date: string;
|
|
18
|
+
dateShort: string;
|
|
19
|
+
year: string;
|
|
20
|
+
eventType: string;
|
|
21
|
+
description: string;
|
|
22
|
+
lang: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const currentLc = computed(() => props.localizedConcepts[props.activeLang]);
|
|
26
|
+
|
|
27
|
+
// Build timeline entries from the localized concept review/history fields
|
|
28
|
+
const timelineEntries = computed((): TimelineEntry[] => {
|
|
29
|
+
const lc = currentLc.value;
|
|
30
|
+
if (!lc) return [];
|
|
31
|
+
|
|
32
|
+
const entries: TimelineEntry[] = [];
|
|
33
|
+
|
|
34
|
+
// gl:dates array — most structured source
|
|
35
|
+
if (lc['gl:dates']?.length) {
|
|
36
|
+
for (const d of lc['gl:dates']) {
|
|
37
|
+
const dateType = d['gl:dateType'] || 'unknown';
|
|
38
|
+
const dateStr = d['gl:date'] || '';
|
|
39
|
+
entries.push({
|
|
40
|
+
date: dateStr,
|
|
41
|
+
dateShort: formatDate(dateStr),
|
|
42
|
+
year: extractYear(dateStr),
|
|
43
|
+
eventType: dateType,
|
|
44
|
+
description: dateTypeLabel(dateType),
|
|
45
|
+
lang: props.activeLang,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Review date
|
|
51
|
+
if (lc['gl:reviewDate']) {
|
|
52
|
+
if (!entries.some(e => e.date === lc['gl:reviewDate'])) {
|
|
53
|
+
entries.push({
|
|
54
|
+
date: lc['gl:reviewDate'],
|
|
55
|
+
dateShort: formatDate(lc['gl:reviewDate']),
|
|
56
|
+
year: extractYear(lc['gl:reviewDate']),
|
|
57
|
+
eventType: 'review',
|
|
58
|
+
description: 'Review initiated',
|
|
59
|
+
lang: props.activeLang,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Review decision date
|
|
65
|
+
if (lc['gl:reviewDecisionDate']) {
|
|
66
|
+
if (!entries.some(e => e.date === lc['gl:reviewDecisionDate'] && e.eventType !== 'review')) {
|
|
67
|
+
entries.push({
|
|
68
|
+
date: lc['gl:reviewDecisionDate'],
|
|
69
|
+
dateShort: formatDate(lc['gl:reviewDecisionDate']),
|
|
70
|
+
year: extractYear(lc['gl:reviewDecisionDate']),
|
|
71
|
+
eventType: 'decision',
|
|
72
|
+
description: lc['gl:reviewDecisionEvent'] || 'Review decision',
|
|
73
|
+
lang: props.activeLang,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Sort by date ascending
|
|
79
|
+
entries.sort((a, b) => a.date.localeCompare(b.date));
|
|
80
|
+
|
|
81
|
+
return entries;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const hasHistory = computed(() => timelineEntries.value.length > 0);
|
|
85
|
+
|
|
86
|
+
// Group entries by year for long timelines
|
|
87
|
+
const groupedByYear = computed(() => {
|
|
88
|
+
const entries = timelineEntries.value;
|
|
89
|
+
if (entries.length <= 3) return null;
|
|
90
|
+
|
|
91
|
+
const groups: { year: string; entries: TimelineEntry[] }[] = [];
|
|
92
|
+
let currentYear = '';
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
if (entry.year !== currentYear) {
|
|
95
|
+
currentYear = entry.year;
|
|
96
|
+
groups.push({ year: currentYear, entries: [entry] });
|
|
97
|
+
} else {
|
|
98
|
+
groups[groups.length - 1].entries.push(entry);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return groups;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Review metadata (decision, status, notes)
|
|
105
|
+
const reviewMeta = computed(() => {
|
|
106
|
+
const lc = currentLc.value;
|
|
107
|
+
if (!lc) return null;
|
|
108
|
+
const fields: { key: string; label: string; value: string }[] = [];
|
|
109
|
+
if (lc['gl:reviewStatus']) fields.push({ key: 'status', label: 'Review Status', value: lc['gl:reviewStatus'] });
|
|
110
|
+
if (lc['gl:reviewDecision']) fields.push({ key: 'decision', label: 'Decision', value: lc['gl:reviewDecision'] });
|
|
111
|
+
if (lc['gl:reviewDecisionNotes']) fields.push({ key: 'notes', label: 'Change Notes', value: lc['gl:reviewDecisionNotes'] });
|
|
112
|
+
if (lc['gl:entryStatus']) fields.push({ key: 'entry', label: 'Entry Status', value: lc['gl:entryStatus'] });
|
|
113
|
+
if (lc['gl:release'] != null) fields.push({ key: 'release', label: 'Release', value: String(lc['gl:release']) });
|
|
114
|
+
return fields.length ? fields : null;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Review event (for prominent display)
|
|
118
|
+
const reviewEvent = computed(() => {
|
|
119
|
+
const lc = currentLc.value;
|
|
120
|
+
if (!lc) return null;
|
|
121
|
+
return lc['gl:reviewDecisionEvent'] || null;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Which languages have any history data
|
|
125
|
+
const languagesWithHistory = computed(() => {
|
|
126
|
+
const langs: string[] = [];
|
|
127
|
+
for (const [lang, lc] of Object.entries(props.localizedConcepts)) {
|
|
128
|
+
if (
|
|
129
|
+
lc['gl:dates']?.length ||
|
|
130
|
+
lc['gl:reviewDate'] ||
|
|
131
|
+
lc['gl:reviewDecisionDate'] ||
|
|
132
|
+
lc['gl:reviewDecisionEvent'] ||
|
|
133
|
+
lc['gl:reviewDecisionNotes']
|
|
134
|
+
) {
|
|
135
|
+
langs.push(lang);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const order = props.languageOrder;
|
|
139
|
+
if (order) {
|
|
140
|
+
const orderIndex = new Map(order.map((l, i) => [l, i]));
|
|
141
|
+
langs.sort((a, b) => {
|
|
142
|
+
const ai = orderIndex.get(a) ?? order.length;
|
|
143
|
+
const bi = orderIndex.get(b) ?? order.length;
|
|
144
|
+
if (ai !== bi) return ai - bi;
|
|
145
|
+
return a.localeCompare(b);
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
langs.sort();
|
|
149
|
+
}
|
|
150
|
+
return langs;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
function formatDate(isoDate: string): string {
|
|
154
|
+
if (!isoDate) return '\u2014';
|
|
155
|
+
try {
|
|
156
|
+
const d = new Date(isoDate);
|
|
157
|
+
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
158
|
+
} catch {
|
|
159
|
+
return isoDate.slice(0, 10);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function extractYear(isoDate: string): string {
|
|
164
|
+
if (!isoDate) return '';
|
|
165
|
+
return isoDate.slice(0, 4);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function dateTypeLabel(type: string): string {
|
|
169
|
+
const labels: Record<string, string> = {
|
|
170
|
+
accepted: 'Concept accepted',
|
|
171
|
+
amended: 'Definition amended',
|
|
172
|
+
superseded: 'Concept superseded',
|
|
173
|
+
withdrawn: 'Concept withdrawn',
|
|
174
|
+
published: 'Published',
|
|
175
|
+
review: 'Under review',
|
|
176
|
+
};
|
|
177
|
+
return labels[type] || type;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function eventColor(type: string): string {
|
|
181
|
+
const colors: Record<string, string> = {
|
|
182
|
+
accepted: 'bg-emerald-100 text-emerald-700',
|
|
183
|
+
amended: 'bg-amber-100 text-amber-700',
|
|
184
|
+
superseded: 'bg-red-100 text-red-700',
|
|
185
|
+
withdrawn: 'bg-red-100 text-red-700',
|
|
186
|
+
published: 'bg-blue-100 text-blue-700',
|
|
187
|
+
decision: 'bg-purple-100 text-purple-700',
|
|
188
|
+
review: 'bg-ink-100 text-ink-600',
|
|
189
|
+
};
|
|
190
|
+
return colors[type] || 'bg-ink-50 text-ink-500';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function eventDotColor(type: string): string {
|
|
194
|
+
const colors: Record<string, string> = {
|
|
195
|
+
accepted: 'bg-emerald-500',
|
|
196
|
+
amended: 'bg-amber-500',
|
|
197
|
+
superseded: 'bg-red-500',
|
|
198
|
+
withdrawn: 'bg-red-500',
|
|
199
|
+
published: 'bg-blue-500',
|
|
200
|
+
decision: 'bg-purple-500',
|
|
201
|
+
review: 'bg-ink-400',
|
|
202
|
+
};
|
|
203
|
+
return colors[type] || 'bg-ink-200';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function eventRingColor(type: string): string {
|
|
207
|
+
const colors: Record<string, string> = {
|
|
208
|
+
accepted: 'ring-emerald-200',
|
|
209
|
+
amended: 'ring-amber-200',
|
|
210
|
+
superseded: 'ring-red-200',
|
|
211
|
+
withdrawn: 'ring-red-200',
|
|
212
|
+
published: 'ring-blue-200',
|
|
213
|
+
decision: 'ring-purple-200',
|
|
214
|
+
review: 'ring-ink-100',
|
|
215
|
+
};
|
|
216
|
+
return colors[type] || 'ring-ink-100';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function entryStatusColor(status: string): string {
|
|
220
|
+
if (status === 'valid' || status === 'Standard') return 'badge-green';
|
|
221
|
+
if (status === 'superseded') return 'bg-red-50 text-red-700';
|
|
222
|
+
if (status === 'withdrawn') return 'bg-red-100 text-red-800';
|
|
223
|
+
if (status === 'draft') return 'badge-yellow';
|
|
224
|
+
return 'badge-gray';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function eventIconPath(type: string): string {
|
|
228
|
+
// Returns an SVG path for the event type icon
|
|
229
|
+
switch (type) {
|
|
230
|
+
case 'accepted':
|
|
231
|
+
return 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'; // circle check
|
|
232
|
+
case 'amended':
|
|
233
|
+
return 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z'; // pencil edit
|
|
234
|
+
case 'superseded':
|
|
235
|
+
case 'withdrawn':
|
|
236
|
+
return 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'; // warning
|
|
237
|
+
case 'decision':
|
|
238
|
+
return 'M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z'; // badge check
|
|
239
|
+
case 'review':
|
|
240
|
+
return 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'; // search
|
|
241
|
+
default:
|
|
242
|
+
return 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'; // info
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
</script>
|
|
246
|
+
|
|
247
|
+
<template>
|
|
248
|
+
<div class="space-y-6">
|
|
249
|
+
<!-- Language selector for history -->
|
|
250
|
+
<div v-if="languagesWithHistory.length > 1" class="flex flex-wrap gap-1.5">
|
|
251
|
+
<button
|
|
252
|
+
v-for="lang in languagesWithHistory"
|
|
253
|
+
:key="lang"
|
|
254
|
+
@click="emit('update:activeLang', lang)"
|
|
255
|
+
:class="[
|
|
256
|
+
activeLang === lang
|
|
257
|
+
? 'bg-ink-800 text-white'
|
|
258
|
+
: 'bg-surface-raised text-ink-600 hover:bg-ink-50 border border-ink-100'
|
|
259
|
+
]"
|
|
260
|
+
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5"
|
|
261
|
+
>
|
|
262
|
+
<span
|
|
263
|
+
class="text-xs font-semibold px-1.5 py-0.5 rounded"
|
|
264
|
+
:class="activeLang === lang ? 'bg-ink-700 text-ink-200' : 'bg-ink-50 text-ink-500'"
|
|
265
|
+
>{{ langLabel(lang) }}</span>
|
|
266
|
+
{{ langName(lang) }}
|
|
267
|
+
</button>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<!-- Empty state -->
|
|
271
|
+
<div v-if="!hasHistory && !reviewMeta" class="card p-6 text-center">
|
|
272
|
+
<div class="text-ink-200 text-4xl font-serif mb-2">∅</div>
|
|
273
|
+
<p class="text-sm text-ink-400">No history data available for {{ langName(activeLang) }}.</p>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<!-- Review event banner (prominent) -->
|
|
277
|
+
<div
|
|
278
|
+
v-if="reviewEvent && hasHistory"
|
|
279
|
+
class="card p-4 flex items-start gap-3 border-l-2"
|
|
280
|
+
:class="[
|
|
281
|
+
currentLc?.['gl:entryStatus'] === 'superseded' ? 'border-l-red-400' : 'border-l-purple-400'
|
|
282
|
+
]"
|
|
283
|
+
>
|
|
284
|
+
<div class="w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5"
|
|
285
|
+
:class="eventColor('decision')"
|
|
286
|
+
>
|
|
287
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
288
|
+
<path stroke-linecap="round" stroke-linejoin="round" :d="eventIconPath('decision')" />
|
|
289
|
+
</svg>
|
|
290
|
+
</div>
|
|
291
|
+
<div class="min-w-0">
|
|
292
|
+
<div class="text-sm font-medium text-ink-800">{{ reviewEvent }}</div>
|
|
293
|
+
<div v-if="currentLc?.['gl:reviewDecisionDate']" class="text-xs text-ink-300 mt-0.5">
|
|
294
|
+
{{ formatDate(currentLc['gl:reviewDecisionDate']) }}
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
<!-- Timeline: Grouped by year (for >3 entries) -->
|
|
300
|
+
<div v-if="hasHistory && groupedByYear" class="relative pl-10">
|
|
301
|
+
<!-- Vertical line -->
|
|
302
|
+
<div class="absolute left-[17px] top-3 bottom-3 w-px bg-ink-100/80"></div>
|
|
303
|
+
|
|
304
|
+
<div class="space-y-6">
|
|
305
|
+
<div v-for="(group, gi) in groupedByYear" :key="group.year">
|
|
306
|
+
<!-- Year marker -->
|
|
307
|
+
<div class="relative -ml-10 flex items-center gap-3 mb-3">
|
|
308
|
+
<div class="w-9 h-9 rounded-full bg-surface-raised border border-ink-200/60 flex items-center justify-center relative z-10"
|
|
309
|
+
style="box-shadow: 0 0 0 3px var(--surface-color, #faf9f6);"
|
|
310
|
+
>
|
|
311
|
+
<span class="text-[11px] font-semibold text-ink-500 font-mono">{{ group.year }}</span>
|
|
312
|
+
</div>
|
|
313
|
+
<div class="h-px flex-1 bg-ink-100/60"></div>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<!-- Events in this year -->
|
|
317
|
+
<div class="space-y-3 ml-0">
|
|
318
|
+
<div
|
|
319
|
+
v-for="(entry, i) in group.entries"
|
|
320
|
+
:key="gi + '-' + i"
|
|
321
|
+
class="relative group"
|
|
322
|
+
:style="{ animationDelay: `${(gi * 2 + i) * 60}ms` }"
|
|
323
|
+
>
|
|
324
|
+
<!-- Node dot -->
|
|
325
|
+
<div class="absolute -left-[29px] top-3.5 w-[11px] h-[11px] rounded-full ring-[3px] z-10"
|
|
326
|
+
:class="[eventDotColor(entry.eventType), eventRingColor(entry.eventType)]"
|
|
327
|
+
style="box-shadow: 0 0 0 2px var(--surface-color, #faf9f6);"
|
|
328
|
+
></div>
|
|
329
|
+
|
|
330
|
+
<!-- Content -->
|
|
331
|
+
<div class="card p-3.5 ml-1">
|
|
332
|
+
<div class="flex items-center gap-2 mb-1">
|
|
333
|
+
<div class="w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0"
|
|
334
|
+
:class="eventColor(entry.eventType)"
|
|
335
|
+
>
|
|
336
|
+
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
|
337
|
+
<path stroke-linecap="round" stroke-linejoin="round" :d="eventIconPath(entry.eventType)" />
|
|
338
|
+
</svg>
|
|
339
|
+
</div>
|
|
340
|
+
<span class="text-[10px] font-semibold uppercase tracking-wider" :class="eventColor(entry.eventType)">
|
|
341
|
+
{{ entry.eventType }}
|
|
342
|
+
</span>
|
|
343
|
+
<span class="text-[11px] text-ink-300 ml-auto tabular-nums">{{ entry.dateShort }}</span>
|
|
344
|
+
</div>
|
|
345
|
+
<p class="text-sm text-ink-700 font-medium leading-snug">{{ entry.description }}</p>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
<!-- Timeline: Simple (for <=3 entries) -->
|
|
354
|
+
<div v-if="hasHistory && !groupedByYear" class="relative pl-10">
|
|
355
|
+
<!-- Vertical line -->
|
|
356
|
+
<div class="absolute left-[17px] top-3 bottom-3 w-px bg-ink-100/80"></div>
|
|
357
|
+
|
|
358
|
+
<div class="space-y-4">
|
|
359
|
+
<div
|
|
360
|
+
v-for="(entry, i) in timelineEntries"
|
|
361
|
+
:key="i"
|
|
362
|
+
class="relative"
|
|
363
|
+
>
|
|
364
|
+
<!-- Node dot -->
|
|
365
|
+
<div class="absolute -left-[29px] top-3.5 w-[11px] h-[11px] rounded-full ring-[3px] z-10"
|
|
366
|
+
:class="[eventDotColor(entry.eventType), eventRingColor(entry.eventType)]"
|
|
367
|
+
style="box-shadow: 0 0 0 2px var(--surface-color, #faf9f6);"
|
|
368
|
+
></div>
|
|
369
|
+
|
|
370
|
+
<!-- Content -->
|
|
371
|
+
<div class="card p-3.5 ml-1">
|
|
372
|
+
<div class="flex items-center gap-2 mb-1">
|
|
373
|
+
<div class="w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0"
|
|
374
|
+
:class="eventColor(entry.eventType)"
|
|
375
|
+
>
|
|
376
|
+
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
|
377
|
+
<path stroke-linecap="round" stroke-linejoin="round" :d="eventIconPath(entry.eventType)" />
|
|
378
|
+
</svg>
|
|
379
|
+
</div>
|
|
380
|
+
<span class="text-[10px] font-semibold uppercase tracking-wider" :class="eventColor(entry.eventType)">
|
|
381
|
+
{{ entry.eventType }}
|
|
382
|
+
</span>
|
|
383
|
+
<span class="text-[11px] text-ink-300 ml-auto tabular-nums">{{ entry.dateShort }}</span>
|
|
384
|
+
</div>
|
|
385
|
+
<p class="text-sm text-ink-700 font-medium leading-snug">{{ entry.description }}</p>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
|
|
391
|
+
<!-- Review metadata table -->
|
|
392
|
+
<div v-if="reviewMeta" class="card p-5">
|
|
393
|
+
<div class="section-label">Review Details</div>
|
|
394
|
+
<dl class="mt-3 space-y-3">
|
|
395
|
+
<template v-for="field in reviewMeta" :key="field.key">
|
|
396
|
+
<div v-if="field.key === 'notes'" class="bg-ink-50/50 rounded-lg p-3.5">
|
|
397
|
+
<dt class="text-[11px] text-ink-400 font-semibold uppercase tracking-wider mb-1.5">{{ field.label }}</dt>
|
|
398
|
+
<dd class="text-sm text-ink-700 leading-relaxed">{{ field.value }}</dd>
|
|
399
|
+
</div>
|
|
400
|
+
<div v-else class="flex items-center gap-3">
|
|
401
|
+
<dt class="text-xs text-ink-300 font-medium min-w-[120px]">{{ field.label }}</dt>
|
|
402
|
+
<dd class="text-sm text-ink-700">
|
|
403
|
+
<span :class="field.key === 'entry' ? 'badge ' + entryStatusColor(field.value) : ''">{{ field.value }}</span>
|
|
404
|
+
</dd>
|
|
405
|
+
</div>
|
|
406
|
+
</template>
|
|
407
|
+
</dl>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
</template>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import { FORMAT_REGISTRY } from '../utils/concept-formats';
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
registerId: string;
|
|
7
|
+
conceptId: string;
|
|
8
|
+
formats: string[];
|
|
9
|
+
}>();
|
|
10
|
+
|
|
11
|
+
interface FormatLink {
|
|
12
|
+
key: string;
|
|
13
|
+
label: string;
|
|
14
|
+
url: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const links = computed<FormatLink[]>(() =>
|
|
18
|
+
props.formats
|
|
19
|
+
.filter(f => FORMAT_REGISTRY[f])
|
|
20
|
+
.map(f => ({
|
|
21
|
+
key: f,
|
|
22
|
+
label: FORMAT_REGISTRY[f].label,
|
|
23
|
+
url: `/data/${props.registerId}/concepts/${props.conceptId}.${FORMAT_REGISTRY[f].extension}`,
|
|
24
|
+
})),
|
|
25
|
+
);
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<div v-if="links.length" class="space-y-2">
|
|
30
|
+
<div class="section-label">Downloads</div>
|
|
31
|
+
<div class="flex flex-wrap gap-2">
|
|
32
|
+
<a
|
|
33
|
+
v-for="link in links"
|
|
34
|
+
:key="link.key"
|
|
35
|
+
:href="link.url"
|
|
36
|
+
:download="`${conceptId}.${FORMAT_REGISTRY[link.key].extension}`"
|
|
37
|
+
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium bg-ink-50 text-ink-600 hover:bg-ink-100 hover:text-ink-800 transition-colors border border-ink-100"
|
|
38
|
+
>
|
|
39
|
+
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
40
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
41
|
+
</svg>
|
|
42
|
+
{{ link.label }}
|
|
43
|
+
</a>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</template>
|