@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,168 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, onMounted } from 'vue';
|
|
3
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
4
|
+
import { useRouter } from 'vue-router';
|
|
5
|
+
import { useDsStyle } from '../utils/dataset-style';
|
|
6
|
+
import { useSiteConfig } from '../config/use-site-config';
|
|
7
|
+
|
|
8
|
+
const store = useVocabularyStore();
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
const { getStyle } = useDsStyle();
|
|
11
|
+
const { config: siteConfig, loadConfig } = useSiteConfig();
|
|
12
|
+
const exploring = ref(false);
|
|
13
|
+
|
|
14
|
+
onMounted(async () => {
|
|
15
|
+
await loadConfig();
|
|
16
|
+
if (siteConfig.value?.defaultDataset) {
|
|
17
|
+
const targetId = siteConfig.value.defaultDataset;
|
|
18
|
+
if (!store.initialized) await store.discoverDatasets();
|
|
19
|
+
if (store.datasets.has(targetId)) {
|
|
20
|
+
router.replace({ name: 'dataset', params: { registerId: targetId } });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
async function exploreRandom() {
|
|
26
|
+
exploring.value = true;
|
|
27
|
+
try {
|
|
28
|
+
if (!store.initialized) await store.discoverDatasets();
|
|
29
|
+
const loaded = [...store.datasets.values()].filter(a => a.index);
|
|
30
|
+
if (!loaded.length) {
|
|
31
|
+
const first = filteredDatasets.value[0];
|
|
32
|
+
if (first) await store.loadDataset(first.id);
|
|
33
|
+
}
|
|
34
|
+
const result = await store.getRandomConcept();
|
|
35
|
+
if (result) {
|
|
36
|
+
await store.viewConcept(result.registerId, result.conceptId);
|
|
37
|
+
router.push({ name: 'concept', params: { registerId: result.registerId, conceptId: result.conceptId } });
|
|
38
|
+
}
|
|
39
|
+
} finally {
|
|
40
|
+
exploring.value = false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const filteredDatasets = computed(() => {
|
|
45
|
+
if (!siteConfig.value) return store.datasetList;
|
|
46
|
+
const allowed = new Set(siteConfig.value.datasets);
|
|
47
|
+
return store.datasetList.filter(ds => allowed.has(ds.id));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const totalConcepts = computed(() =>
|
|
51
|
+
filteredDatasets.value.reduce((sum, ds) => sum + ds.manifest.conceptCount, 0)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const totalLanguages = computed(() => {
|
|
55
|
+
const langs = new Set<string>();
|
|
56
|
+
for (const ds of filteredDatasets.value) {
|
|
57
|
+
for (const l of ds.manifest.languages) langs.add(l);
|
|
58
|
+
}
|
|
59
|
+
return langs.size;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function goToDataset(id: string) {
|
|
63
|
+
router.push({ name: 'dataset', params: { registerId: id } });
|
|
64
|
+
}
|
|
65
|
+
function goToSearch() { router.push({ name: 'search' }); }
|
|
66
|
+
function goToGraph() { router.push({ name: 'graph' }); }
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-10">
|
|
71
|
+
<!-- Hero -->
|
|
72
|
+
<div class="mb-10 sm:mb-14">
|
|
73
|
+
<div class="flex items-center gap-2 mb-4">
|
|
74
|
+
<span class="text-[11px] font-semibold uppercase tracking-[0.2em] text-ink-300">{{ siteConfig?.branding?.ownerName || 'Glossarist' }}</span>
|
|
75
|
+
<span class="w-4 sm:w-6 h-px bg-ink-200"></span>
|
|
76
|
+
<span class="text-[11px] font-semibold uppercase tracking-[0.2em] text-ink-300 hidden sm:inline">{{ siteConfig?.subtitle || 'Terminology Register' }}</span>
|
|
77
|
+
</div>
|
|
78
|
+
<h1 class="font-serif text-[2rem] sm:text-[2.75rem] text-ink-800 leading-[1.1] mb-4 tracking-tight">
|
|
79
|
+
{{ siteConfig?.title || 'Glossarist' }}<br class="hidden sm:block" /> <span v-if="siteConfig?.subtitle">{{ siteConfig.subtitle }}</span>
|
|
80
|
+
<template v-if="!siteConfig?.subtitle">Terminology<br class="hidden sm:block" /> Register</template>
|
|
81
|
+
</h1>
|
|
82
|
+
<p class="text-base text-ink-400 max-w-lg leading-relaxed">
|
|
83
|
+
Explore standardized terminology datasets from ISO and IEC technical committees.
|
|
84
|
+
Browse concepts, definitions, and cross-references across multilingual vocabularies.
|
|
85
|
+
</p>
|
|
86
|
+
<div class="flex flex-wrap gap-3 mt-7">
|
|
87
|
+
<button @click="goToSearch" class="btn-primary flex items-center gap-2">
|
|
88
|
+
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
|
89
|
+
Search
|
|
90
|
+
</button>
|
|
91
|
+
<button @click="goToGraph" class="btn-secondary flex items-center gap-2">
|
|
92
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
|
|
93
|
+
Graph View
|
|
94
|
+
</button>
|
|
95
|
+
<button @click="exploreRandom" :disabled="exploring" class="btn-secondary flex items-center gap-2">
|
|
96
|
+
<svg v-if="!exploring" 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="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
|
97
|
+
<svg v-else class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
|
98
|
+
{{ exploring ? 'Exploring…' : 'Surprise Me' }}
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<!-- Stats -->
|
|
104
|
+
<div class="grid grid-cols-1 sm:grid-cols-3 gap-px bg-ink-100/60 rounded-xl overflow-hidden mb-10 sm:mb-14">
|
|
105
|
+
<div class="bg-surface-raised px-4 sm:px-6 py-5 animate-entrance" style="animation-delay: 80ms">
|
|
106
|
+
<div class="text-3xl font-serif text-ink-800 tabular-nums">{{ filteredDatasets.length }}</div>
|
|
107
|
+
<div class="text-sm text-ink-400 mt-1">Datasets</div>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="bg-surface-raised px-6 py-5 animate-entrance" style="animation-delay: 140ms">
|
|
110
|
+
<div class="text-3xl font-serif text-ink-800 tabular-nums">{{ totalConcepts.toLocaleString() }}</div>
|
|
111
|
+
<div class="text-sm text-ink-400 mt-1">Concepts</div>
|
|
112
|
+
</div>
|
|
113
|
+
<div class="bg-surface-raised px-6 py-5 animate-entrance" style="animation-delay: 200ms">
|
|
114
|
+
<div class="text-3xl font-serif text-ink-800 tabular-nums">{{ totalLanguages }}</div>
|
|
115
|
+
<div class="text-sm text-ink-400 mt-1">Languages</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<!-- Dataset cards -->
|
|
120
|
+
<div class="flex items-center justify-between mb-5">
|
|
121
|
+
<div class="section-label mb-0">Available Datasets</div>
|
|
122
|
+
<span class="text-xs text-ink-300">Click to browse</span>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
125
|
+
<button
|
|
126
|
+
v-for="(ds, idx) in filteredDatasets"
|
|
127
|
+
:key="ds.id"
|
|
128
|
+
@click="goToDataset(ds.id)"
|
|
129
|
+
class="card-hover p-6 text-left group animate-entrance"
|
|
130
|
+
:style="{ borderLeft: `3px solid ${getStyle(ds.id).color}`, animationDelay: `${idx * 60}ms` }"
|
|
131
|
+
>
|
|
132
|
+
<div class="flex items-start gap-3 mb-4">
|
|
133
|
+
<span class="w-2.5 h-2.5 rounded-full mt-1.5 flex-shrink-0" :style="{ backgroundColor: getStyle(ds.id).color }"></span>
|
|
134
|
+
<div class="min-w-0">
|
|
135
|
+
<h2 class="font-serif text-xl text-ink-800 leading-snug group-hover:text-ink-900 transition-colors">
|
|
136
|
+
{{ ds.manifest.title }}
|
|
137
|
+
</h2>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<p class="text-sm text-ink-400 mb-5 line-clamp-2 leading-relaxed pl-[22px]">
|
|
142
|
+
{{ ds.manifest.description }}
|
|
143
|
+
</p>
|
|
144
|
+
|
|
145
|
+
<div class="flex items-center gap-3 pl-[22px] mb-3">
|
|
146
|
+
<span :style="{ color: getStyle(ds.id).color }" class="text-sm font-semibold tabular-nums">{{ ds.manifest.conceptCount.toLocaleString() }}</span>
|
|
147
|
+
<span class="text-xs text-ink-300">concepts</span>
|
|
148
|
+
<span class="text-ink-200 text-xs">·</span>
|
|
149
|
+
<span class="text-sm text-ink-500 tabular-nums">{{ ds.manifest.languages.length }}</span>
|
|
150
|
+
<span class="text-xs text-ink-300">languages</span>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div class="flex flex-wrap gap-1.5 pl-[22px] mb-3">
|
|
154
|
+
<span v-for="tag in (ds.manifest.tags ?? []).slice(0, 3)" :key="tag" class="badge text-[10px]" :style="{ backgroundColor: getStyle(ds.id).light, color: getStyle(ds.id).dark }">
|
|
155
|
+
{{ tag }}
|
|
156
|
+
</span>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<div class="flex items-center justify-between pl-[22px]">
|
|
160
|
+
<span class="text-[11px] text-ink-300">{{ ds.manifest.owner }}</span>
|
|
161
|
+
<svg class="w-4 h-4 text-ink-200 group-hover:text-ink-400 group-hover:translate-x-0.5 transition-all" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
162
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
|
163
|
+
</svg>
|
|
164
|
+
</div>
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</template>
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted } from 'vue';
|
|
3
|
+
import { useSiteConfig } from '../config/use-site-config';
|
|
4
|
+
import { renderAsciiDocLite } from '../utils/asciidoc-lite';
|
|
5
|
+
|
|
6
|
+
interface NewsPost {
|
|
7
|
+
slug: string;
|
|
8
|
+
title: string;
|
|
9
|
+
date: string;
|
|
10
|
+
categories: string[];
|
|
11
|
+
file: string;
|
|
12
|
+
excerpt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { config } = useSiteConfig();
|
|
16
|
+
const posts = ref<NewsPost[]>([]);
|
|
17
|
+
const loading = ref(true);
|
|
18
|
+
const error = ref<string | null>(null);
|
|
19
|
+
|
|
20
|
+
const activeSlug = ref<string | null>(null);
|
|
21
|
+
const activeHtml = ref('');
|
|
22
|
+
const activeLoading = ref(false);
|
|
23
|
+
|
|
24
|
+
onMounted(async () => {
|
|
25
|
+
try {
|
|
26
|
+
const resp = await fetch('/news.json');
|
|
27
|
+
if (resp.ok) posts.value = await resp.json();
|
|
28
|
+
} catch (e: any) {
|
|
29
|
+
error.value = e.message;
|
|
30
|
+
}
|
|
31
|
+
loading.value = false;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
async function openPost(post: NewsPost) {
|
|
35
|
+
if (activeSlug.value === post.slug) {
|
|
36
|
+
activeSlug.value = null;
|
|
37
|
+
activeHtml.value = '';
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
activeSlug.value = post.slug;
|
|
41
|
+
activeLoading.value = true;
|
|
42
|
+
try {
|
|
43
|
+
const resp = await fetch(post.file);
|
|
44
|
+
if (resp.ok) {
|
|
45
|
+
const text = await resp.text();
|
|
46
|
+
const body = stripFrontmatter(text);
|
|
47
|
+
activeHtml.value = renderAsciiDocLite(body);
|
|
48
|
+
}
|
|
49
|
+
} catch { /* ignore */ }
|
|
50
|
+
activeLoading.value = false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stripFrontmatter(text: string): string {
|
|
54
|
+
const lines = text.split('\n');
|
|
55
|
+
if (lines[0] !== '---') return text;
|
|
56
|
+
let end = -1;
|
|
57
|
+
for (let i = 1; i < lines.length; i++) {
|
|
58
|
+
if (lines[i] === '---') { end = i; break; }
|
|
59
|
+
}
|
|
60
|
+
if (end < 0) return text;
|
|
61
|
+
return lines.slice(end + 1).join('\n').trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatDate(dateStr: string) {
|
|
65
|
+
if (!dateStr) return '';
|
|
66
|
+
try {
|
|
67
|
+
return new Date(dateStr).toLocaleDateString('en-US', {
|
|
68
|
+
year: 'numeric', month: 'long', day: 'numeric',
|
|
69
|
+
});
|
|
70
|
+
} catch {
|
|
71
|
+
return dateStr;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<template>
|
|
77
|
+
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
78
|
+
<h1 class="font-serif text-3xl text-ink-800 mb-2">News</h1>
|
|
79
|
+
<p class="text-ink-400 mb-8">
|
|
80
|
+
Updates from {{ config?.branding?.ownerName || config?.title || 'Glossarist' }}.
|
|
81
|
+
</p>
|
|
82
|
+
|
|
83
|
+
<template v-if="loading">
|
|
84
|
+
<div class="animate-pulse space-y-6">
|
|
85
|
+
<div v-for="i in 3" :key="i" class="card p-6 space-y-3">
|
|
86
|
+
<div class="h-3 bg-ink-100 rounded w-24"></div>
|
|
87
|
+
<div class="h-6 bg-ink-100 rounded w-3/4"></div>
|
|
88
|
+
<div class="h-4 bg-ink-100 rounded w-full"></div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</template>
|
|
92
|
+
|
|
93
|
+
<template v-else-if="error">
|
|
94
|
+
<div class="card p-8 text-center">
|
|
95
|
+
<p class="text-ink-500">Failed to load news posts.</p>
|
|
96
|
+
</div>
|
|
97
|
+
</template>
|
|
98
|
+
|
|
99
|
+
<template v-else-if="posts.length === 0">
|
|
100
|
+
<div class="card p-8 text-center">
|
|
101
|
+
<p class="text-ink-500">No news posts yet.</p>
|
|
102
|
+
</div>
|
|
103
|
+
</template>
|
|
104
|
+
|
|
105
|
+
<template v-else>
|
|
106
|
+
<div class="space-y-4">
|
|
107
|
+
<article v-for="post in posts" :key="post.slug">
|
|
108
|
+
<button
|
|
109
|
+
@click="openPost(post)"
|
|
110
|
+
class="w-full text-left card p-6 hover:bg-surface-alt/50 transition-colors"
|
|
111
|
+
:class="activeSlug === post.slug ? 'ring-1 ring-ink-200' : ''"
|
|
112
|
+
>
|
|
113
|
+
<div class="flex items-center gap-3 text-xs text-ink-400 mb-2">
|
|
114
|
+
<time :datetime="post.date">{{ formatDate(post.date) }}</time>
|
|
115
|
+
<span v-if="post.categories.length" class="flex gap-1">
|
|
116
|
+
<span
|
|
117
|
+
v-for="cat in post.categories"
|
|
118
|
+
:key="cat"
|
|
119
|
+
class="bg-ink-50 text-ink-500 px-1.5 py-0.5 rounded"
|
|
120
|
+
>{{ cat }}</span>
|
|
121
|
+
</span>
|
|
122
|
+
</div>
|
|
123
|
+
<div class="flex items-start justify-between gap-3">
|
|
124
|
+
<h2 class="font-serif text-xl text-ink-800">{{ post.title }}</h2>
|
|
125
|
+
<svg
|
|
126
|
+
class="w-5 h-5 text-ink-300 flex-shrink-0 mt-1 transition-transform"
|
|
127
|
+
:class="activeSlug === post.slug ? 'rotate-180' : ''"
|
|
128
|
+
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
129
|
+
><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 9l-7 7-7-7"/></svg>
|
|
130
|
+
</div>
|
|
131
|
+
<p v-if="activeSlug !== post.slug && post.excerpt" class="text-ink-500 text-sm mt-2 leading-relaxed">{{ post.excerpt }}</p>
|
|
132
|
+
</button>
|
|
133
|
+
|
|
134
|
+
<div v-if="activeSlug === post.slug" class="card border-t-0 rounded-t-none p-6 pt-2 -mt-1">
|
|
135
|
+
<div v-if="activeLoading" class="animate-pulse space-y-2">
|
|
136
|
+
<div class="h-4 bg-ink-100 rounded w-full"></div>
|
|
137
|
+
<div class="h-4 bg-ink-100 rounded w-5/6"></div>
|
|
138
|
+
<div class="h-4 bg-ink-100 rounded w-4/6"></div>
|
|
139
|
+
</div>
|
|
140
|
+
<div v-else class="prose-news" v-html="activeHtml"></div>
|
|
141
|
+
</div>
|
|
142
|
+
</article>
|
|
143
|
+
</div>
|
|
144
|
+
</template>
|
|
145
|
+
</div>
|
|
146
|
+
</template>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, ref } from 'vue';
|
|
3
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
4
|
+
import { getFactory } from '../adapters/factory';
|
|
5
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
6
|
+
|
|
7
|
+
const route = useRoute();
|
|
8
|
+
const router = useRouter();
|
|
9
|
+
const store = useVocabularyStore();
|
|
10
|
+
|
|
11
|
+
const error = ref<string | null>(null);
|
|
12
|
+
const uri = ref('');
|
|
13
|
+
|
|
14
|
+
onMounted(async () => {
|
|
15
|
+
uri.value = decodeURIComponent(route.params.uri as string);
|
|
16
|
+
const factory = getFactory();
|
|
17
|
+
|
|
18
|
+
if (!factory.getAdapters().length) {
|
|
19
|
+
try {
|
|
20
|
+
await store.discoverDatasets();
|
|
21
|
+
} catch {
|
|
22
|
+
error.value = 'Failed to load datasets.';
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const resolution = factory.resolve(uri.value);
|
|
28
|
+
|
|
29
|
+
if (resolution.type === 'internal') {
|
|
30
|
+
if (!store.datasets.has(resolution.registerId)) {
|
|
31
|
+
try {
|
|
32
|
+
await store.loadDataset(resolution.registerId);
|
|
33
|
+
} catch {
|
|
34
|
+
error.value = `Failed to load dataset: ${resolution.registerId}`;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
router.replace({ name: 'concept', params: { registerId: resolution.registerId, conceptId: resolution.conceptId } });
|
|
39
|
+
} else if (resolution.type === 'site') {
|
|
40
|
+
window.location.href = `${resolution.baseUrl}/resolve/${encodeURIComponent(uri.value)}`;
|
|
41
|
+
} else if (resolution.type === 'url') {
|
|
42
|
+
window.location.href = resolution.url;
|
|
43
|
+
} else {
|
|
44
|
+
error.value = 'Concept not found';
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<div class="max-w-3xl mx-auto px-4 py-16 text-center">
|
|
51
|
+
<template v-if="error">
|
|
52
|
+
<h1 class="text-2xl font-serif text-ink-800 mb-4">Concept not found</h1>
|
|
53
|
+
<p class="text-ink-500 mb-2">The following concept URI could not be resolved:</p>
|
|
54
|
+
<code class="text-sm text-ink-600 break-all bg-ink-50 px-3 py-2 rounded">{{ uri }}</code>
|
|
55
|
+
<div class="mt-8">
|
|
56
|
+
<router-link :to="{ name: 'home' }" class="text-blue-600 hover:underline">Return to home</router-link>
|
|
57
|
+
</div>
|
|
58
|
+
</template>
|
|
59
|
+
<template v-else>
|
|
60
|
+
<p class="text-ink-400">Resolving...</p>
|
|
61
|
+
</template>
|
|
62
|
+
</div>
|
|
63
|
+
</template>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted, watch } from 'vue';
|
|
3
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
4
|
+
import SearchBar from '../components/SearchBar.vue';
|
|
5
|
+
import { useUiStore } from '../stores/ui';
|
|
6
|
+
|
|
7
|
+
const route = useRoute();
|
|
8
|
+
const router = useRouter();
|
|
9
|
+
const ui = useUiStore();
|
|
10
|
+
|
|
11
|
+
onMounted(() => {
|
|
12
|
+
if (route.query.q && typeof route.query.q === 'string') {
|
|
13
|
+
ui.searchQuery = route.query.q;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
watch(() => route.query.q, (q) => {
|
|
18
|
+
if (typeof q === 'string' && q) {
|
|
19
|
+
ui.searchQuery = q;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<div class="px-4 sm:px-6 lg:px-8 py-8">
|
|
26
|
+
<nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
|
|
27
|
+
<router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
|
|
28
|
+
<span class="text-ink-200">/</span>
|
|
29
|
+
<span class="text-ink-700">Search</span>
|
|
30
|
+
</nav>
|
|
31
|
+
<SearchBar />
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
4
|
+
import { useDsStyle } from '../utils/dataset-style';
|
|
5
|
+
import { useDatasetLoader } from '../composables/use-dataset-loader';
|
|
6
|
+
import { langName, langLabel } from '../utils/lang';
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{ registerId: string }>();
|
|
9
|
+
|
|
10
|
+
const store = useVocabularyStore();
|
|
11
|
+
const { getColor } = useDsStyle();
|
|
12
|
+
const { loading, localError, ensureLoaded } = useDatasetLoader(() => props.registerId);
|
|
13
|
+
|
|
14
|
+
const manifest = computed(() => store.manifests.get(props.registerId));
|
|
15
|
+
|
|
16
|
+
interface LangStat {
|
|
17
|
+
lang: string;
|
|
18
|
+
terms: number;
|
|
19
|
+
definitions: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const stats = computed(() => {
|
|
23
|
+
const m = manifest.value;
|
|
24
|
+
if (!m) return { langs: [], total: 0 };
|
|
25
|
+
|
|
26
|
+
const ls = m.languageStats || {};
|
|
27
|
+
const langs: LangStat[] = m.languages.map(lang => ({
|
|
28
|
+
lang,
|
|
29
|
+
terms: ls[lang]?.terms ?? 0,
|
|
30
|
+
definitions: ls[lang]?.definitions ?? 0,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
// Sort: eng first, then by term count descending
|
|
34
|
+
langs.sort((a, b) => {
|
|
35
|
+
if (a.lang === 'eng') return -1;
|
|
36
|
+
if (b.lang === 'eng') return 1;
|
|
37
|
+
return b.terms - a.terms;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return { langs, total: m.conceptCount };
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const maxTerms = computed(() => Math.max(...stats.value.langs.map(l => l.terms), 1));
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
48
|
+
<!-- Breadcrumb -->
|
|
49
|
+
<nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
|
|
50
|
+
<router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
|
|
51
|
+
<span class="text-ink-200">/</span>
|
|
52
|
+
<router-link :to="{ name: 'dataset', params: { registerId } }" class="hover:text-ink-700 transition-colors">{{ manifest?.title || registerId }}</router-link>
|
|
53
|
+
<span class="text-ink-200">/</span>
|
|
54
|
+
<span class="text-ink-700">Statistics</span>
|
|
55
|
+
</nav>
|
|
56
|
+
|
|
57
|
+
<template v-if="loading">
|
|
58
|
+
<div class="animate-pulse space-y-6">
|
|
59
|
+
<div class="h-8 bg-ink-100 rounded w-32"></div>
|
|
60
|
+
<div class="h-4 bg-ink-100 rounded w-64"></div>
|
|
61
|
+
<div class="card overflow-hidden">
|
|
62
|
+
<div class="h-80 bg-ink-50"></div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
66
|
+
<template v-else-if="localError">
|
|
67
|
+
<div class="card p-8 border-red-200 bg-red-50/50 text-center">
|
|
68
|
+
<p class="text-red-700 font-medium mb-1">Failed to load statistics</p>
|
|
69
|
+
<p class="text-sm text-red-600/80 mb-4">{{ localError }}</p>
|
|
70
|
+
<button @click="ensureLoaded" class="btn-primary">Retry</button>
|
|
71
|
+
</div>
|
|
72
|
+
</template>
|
|
73
|
+
<template v-else-if="manifest">
|
|
74
|
+
<h1 class="font-serif text-3xl text-ink-800 mb-2">Statistics</h1>
|
|
75
|
+
<p class="text-ink-400 mb-8">
|
|
76
|
+
{{ stats.total.toLocaleString() }} concepts across {{ manifest.languages.length }} languages.
|
|
77
|
+
</p>
|
|
78
|
+
|
|
79
|
+
<!-- Language stats table -->
|
|
80
|
+
<div class="card -mx-4 sm:mx-0 overflow-x-auto">
|
|
81
|
+
<table class="w-full text-sm">
|
|
82
|
+
<thead>
|
|
83
|
+
<tr class="border-b border-ink-100/60 bg-ink-50/50">
|
|
84
|
+
<th class="text-left px-5 py-3 text-ink-500 font-medium">Language</th>
|
|
85
|
+
<th class="text-right px-5 py-3 text-ink-500 font-medium">Terms</th>
|
|
86
|
+
<th class="text-right px-5 py-3 text-ink-500 font-medium">Definitions</th>
|
|
87
|
+
<th class="px-5 py-3 text-ink-500 font-medium w-40"></th>
|
|
88
|
+
</tr>
|
|
89
|
+
</thead>
|
|
90
|
+
<tbody>
|
|
91
|
+
<tr v-for="s in stats.langs" :key="s.lang" class="border-b border-ink-50 last:border-0">
|
|
92
|
+
<td class="px-5 py-3">
|
|
93
|
+
<div class="flex items-center gap-2">
|
|
94
|
+
<span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langLabel(s.lang) }}</span>
|
|
95
|
+
<span class="font-medium text-ink-800">{{ langName(s.lang) }}</span>
|
|
96
|
+
<span class="text-ink-300 text-xs">({{ s.lang }})</span>
|
|
97
|
+
</div>
|
|
98
|
+
</td>
|
|
99
|
+
<td class="text-right px-5 py-3 font-mono text-ink-700">{{ s.terms.toLocaleString() }}</td>
|
|
100
|
+
<td class="text-right px-5 py-3 font-mono text-ink-700">{{ s.definitions.toLocaleString() }}</td>
|
|
101
|
+
<td class="px-5 py-3">
|
|
102
|
+
<div class="flex items-center gap-2">
|
|
103
|
+
<div class="h-2 rounded-full bg-ink-50 overflow-hidden flex-1">
|
|
104
|
+
<div
|
|
105
|
+
class="h-full rounded-full transition-all duration-500"
|
|
106
|
+
:style="{
|
|
107
|
+
width: (s.terms / maxTerms * 100) + '%',
|
|
108
|
+
backgroundColor: getColor(registerId),
|
|
109
|
+
}"
|
|
110
|
+
></div>
|
|
111
|
+
</div>
|
|
112
|
+
<span class="text-xs text-ink-300 w-10 text-right tabular-nums">{{ Math.round(s.terms / maxTerms * 100) }}%</span>
|
|
113
|
+
</div>
|
|
114
|
+
</td>
|
|
115
|
+
</tr>
|
|
116
|
+
</tbody>
|
|
117
|
+
</table>
|
|
118
|
+
</div>
|
|
119
|
+
</template>
|
|
120
|
+
</div>
|
|
121
|
+
</template>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** @type {import('tailwindcss').Config} */
|
|
2
|
+
export default {
|
|
3
|
+
darkMode: 'class',
|
|
4
|
+
content: [
|
|
5
|
+
"./index.html",
|
|
6
|
+
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
|
7
|
+
],
|
|
8
|
+
theme: {
|
|
9
|
+
extend: {
|
|
10
|
+
fontFamily: {
|
|
11
|
+
serif: ['"DM Serif Display"', 'Georgia', 'serif'],
|
|
12
|
+
sans: ['"DM Sans"', 'system-ui', 'sans-serif'],
|
|
13
|
+
mono: ['"JetBrains Mono"', 'monospace'],
|
|
14
|
+
},
|
|
15
|
+
colors: {
|
|
16
|
+
ink: {
|
|
17
|
+
DEFAULT: '#1a1b2e',
|
|
18
|
+
50: '#f0f0f4',
|
|
19
|
+
100: '#dddde6',
|
|
20
|
+
200: '#b8b9cc',
|
|
21
|
+
300: '#8d8faa',
|
|
22
|
+
400: '#636588',
|
|
23
|
+
500: '#484a6e',
|
|
24
|
+
600: '#36385a',
|
|
25
|
+
700: '#2c2e4a',
|
|
26
|
+
800: '#1a1b2e',
|
|
27
|
+
900: '#0f1020',
|
|
28
|
+
},
|
|
29
|
+
surface: {
|
|
30
|
+
DEFAULT: '#faf9f6',
|
|
31
|
+
alt: '#f3f2ee',
|
|
32
|
+
raised: '#ffffff',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
typography: {
|
|
36
|
+
serif: {
|
|
37
|
+
'font-family': '"DM Serif Display", Georgia, serif',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
plugins: [],
|
|
43
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"moduleDetection": "force",
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"jsx": "preserve",
|
|
14
|
+
"strict": true,
|
|
15
|
+
"noUnusedLocals": false,
|
|
16
|
+
"noUnusedParameters": false,
|
|
17
|
+
"noFallthroughCasesInSwitch": true,
|
|
18
|
+
"paths": {
|
|
19
|
+
"@/*": ["./src/*"]
|
|
20
|
+
},
|
|
21
|
+
"baseUrl": "."
|
|
22
|
+
},
|
|
23
|
+
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"]
|
|
24
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import vue from '@vitejs/plugin-vue'
|
|
3
|
+
import { resolve, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const cwd = process.cwd()
|
|
8
|
+
|
|
9
|
+
export default defineConfig({
|
|
10
|
+
base: process.env.BASE_PATH || '/',
|
|
11
|
+
root: __dirname,
|
|
12
|
+
publicDir: resolve(cwd, 'public'),
|
|
13
|
+
build: {
|
|
14
|
+
outDir: resolve(cwd, 'dist'),
|
|
15
|
+
emptyOutDir: true,
|
|
16
|
+
},
|
|
17
|
+
plugins: [vue()],
|
|
18
|
+
resolve: {
|
|
19
|
+
alias: {
|
|
20
|
+
'@': resolve(__dirname, 'src'),
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
test: {
|
|
24
|
+
environment: 'happy-dom',
|
|
25
|
+
globals: true,
|
|
26
|
+
},
|
|
27
|
+
})
|