relaton 3.0.6 → 3.0.8
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.
- checksums.yaml +4 -4
- data/bin/relaton +20 -0
- data/lib/relaton/bib/model/docidentifier.rb +17 -2
- data/lib/relaton/bibcollection.rb +146 -0
- data/lib/relaton/bibdata.rb +92 -0
- data/lib/relaton/bipm/data_outcomes_parser.rb +6 -1
- data/lib/relaton/cli/base_convertor.rb +134 -0
- data/lib/relaton/cli/command.rb +390 -0
- data/lib/relaton/cli/data_fetcher.rb +20 -0
- data/lib/relaton/cli/frontend/dist/404.html +18 -0
- data/lib/relaton/cli/frontend/dist/app.iife.js +1 -0
- data/lib/relaton/cli/frontend/dist/style.css +3 -0
- data/lib/relaton/cli/frontend/index.html +14 -0
- data/lib/relaton/cli/frontend/package-lock.json +2945 -0
- data/lib/relaton/cli/frontend/package.json +29 -0
- data/lib/relaton/cli/frontend/public/404.html +18 -0
- data/lib/relaton/cli/frontend/src/App.test.ts +501 -0
- data/lib/relaton/cli/frontend/src/App.vue +506 -0
- data/lib/relaton/cli/frontend/src/app.ts +90 -0
- data/lib/relaton/cli/frontend/src/components/DocumentDetail.test.ts +142 -0
- data/lib/relaton/cli/frontend/src/components/DocumentDetail.vue +228 -0
- data/lib/relaton/cli/frontend/src/components/DocumentRow.vue +88 -0
- data/lib/relaton/cli/frontend/src/components/Icon.test.ts +44 -0
- data/lib/relaton/cli/frontend/src/components/Icon.vue +55 -0
- data/lib/relaton/cli/frontend/src/components/Pagination.vue +26 -0
- data/lib/relaton/cli/frontend/src/dev/main.ts +100 -0
- data/lib/relaton/cli/frontend/src/dev/sample-data.json +71 -0
- data/lib/relaton/cli/frontend/src/lib/filter.test.ts +88 -0
- data/lib/relaton/cli/frontend/src/lib/filter.ts +95 -0
- data/lib/relaton/cli/frontend/src/lib/hydrate.test.ts +102 -0
- data/lib/relaton/cli/frontend/src/lib/hydrate.ts +55 -0
- data/lib/relaton/cli/frontend/src/lib/shards.test.ts +319 -0
- data/lib/relaton/cli/frontend/src/lib/shards.ts +183 -0
- data/lib/relaton/cli/frontend/src/lib/types.ts +148 -0
- data/lib/relaton/cli/frontend/src/lib/url.test.ts +151 -0
- data/lib/relaton/cli/frontend/src/lib/url.ts +111 -0
- data/lib/relaton/cli/frontend/src/shims-vue.d.ts +5 -0
- data/lib/relaton/cli/frontend/src/styles/index.css +27 -0
- data/lib/relaton/cli/frontend/src/test/fetch.ts +58 -0
- data/lib/relaton/cli/frontend/tsconfig.json +20 -0
- data/lib/relaton/cli/frontend/vite.config.ts +42 -0
- data/lib/relaton/cli/frontend/vitest.config.ts +11 -0
- data/lib/relaton/cli/frontend_assets.rb +58 -0
- data/lib/relaton/cli/full_text_search.rb +98 -0
- data/lib/relaton/cli/i18n.yaml +1 -0
- data/lib/relaton/cli/index_item_normalizer.rb +380 -0
- data/lib/relaton/cli/index_site_generator.rb +1015 -0
- data/lib/relaton/cli/relaton_file.rb +290 -0
- data/lib/relaton/cli/subcommand_collection.rb +263 -0
- data/lib/relaton/cli/subcommand_db.rb +81 -0
- data/lib/relaton/cli/templates/_document.liquid +111 -0
- data/lib/relaton/cli/templates/_index.liquid +61 -0
- data/lib/relaton/cli/templates/index/page.liquid +20 -0
- data/lib/relaton/cli/templates/index-style.css +1017 -0
- data/lib/relaton/cli/util.rb +8 -0
- data/lib/relaton/cli/version.rb +11 -0
- data/lib/relaton/cli/xml_convertor.rb +47 -0
- data/lib/relaton/cli/xml_to_html_renderer.rb +117 -0
- data/lib/relaton/cli/yaml_convertor.rb +102 -0
- data/lib/relaton/cli.rb +113 -0
- data/lib/relaton/doi/CLAUDE.md +1 -1
- data/lib/relaton/element_finder.rb +29 -0
- data/lib/relaton/gb/CLAUDE.md +1 -1
- data/lib/relaton/ietf/CLAUDE.md +1 -1
- data/lib/relaton/iho/CLAUDE.md +3 -3
- data/lib/relaton/iho/bibliography.rb +41 -27
- data/lib/relaton/iho/processor.rb +3 -2
- data/lib/relaton/nist/CLAUDE.md +11 -1
- data/lib/relaton/nist/README.adoc +7 -1
- data/lib/relaton/nist/hit_collection.rb +25 -1
- data/lib/relaton/nist/mods_parser.rb +32 -8
- data/lib/relaton/nist/pubs_export.rb +69 -11
- data/lib/relaton/ogc/CLAUDE.md +1 -1
- data/lib/relaton/omg/CLAUDE.md +1 -1
- data/lib/relaton/plateau/CLAUDE.md +1 -1
- data/lib/relaton/render/general.rb +319 -0
- data/lib/relaton/render/iso690/csl_import.rb +129 -0
- data/lib/relaton/render/iso690/element.rb +153 -0
- data/lib/relaton/render/iso690/elements/access.rb +44 -0
- data/lib/relaton/render/iso690/elements/authorizer.rb +45 -0
- data/lib/relaton/render/iso690/elements/citeid.rb +39 -0
- data/lib/relaton/render/iso690/elements/component_part.rb +97 -0
- data/lib/relaton/render/iso690/elements/creator.rb +267 -0
- data/lib/relaton/render/iso690/elements/edition.rb +97 -0
- data/lib/relaton/render/iso690/elements/extent.rb +71 -0
- data/lib/relaton/render/iso690/elements/identifier.rb +46 -0
- data/lib/relaton/render/iso690/elements/location.rb +73 -0
- data/lib/relaton/render/iso690/elements/medium.rb +32 -0
- data/lib/relaton/render/iso690/elements/numeration.rb +26 -0
- data/lib/relaton/render/iso690/elements/production.rb +111 -0
- data/lib/relaton/render/iso690/elements/pub_date.rb +63 -0
- data/lib/relaton/render/iso690/elements/series.rb +66 -0
- data/lib/relaton/render/iso690/elements/size.rb +45 -0
- data/lib/relaton/render/iso690/elements/status.rb +30 -0
- data/lib/relaton/render/iso690/elements/stddoc.rb +40 -0
- data/lib/relaton/render/iso690/elements/title.rb +56 -0
- data/lib/relaton/render/iso690/elements/updated.rb +38 -0
- data/lib/relaton/render/iso690/elements.rb +67 -0
- data/lib/relaton/render/iso690/fields.rb +142 -0
- data/lib/relaton/render/iso690/i18n/en.yml +48 -0
- data/lib/relaton/render/iso690/i18n/fr.yml +14 -0
- data/lib/relaton/render/iso690/i18n/ja.yml +38 -0
- data/lib/relaton/render/iso690/i18n.rb +83 -0
- data/lib/relaton/render/iso690/identifier_kinds.rb +29 -0
- data/lib/relaton/render/iso690/index_renderer.rb +57 -0
- data/lib/relaton/render/iso690/kinds.rb +53 -0
- data/lib/relaton/render/iso690/renderer.rb +150 -0
- data/lib/relaton/render/iso690/rules/bipm.rb +186 -0
- data/lib/relaton/render/iso690/rules/jis.rb +103 -0
- data/lib/relaton/render/iso690/rules/nist.rb +384 -0
- data/lib/relaton/render/iso690/rules.rb +227 -0
- data/lib/relaton/render/iso690/style/locale.rb +59 -0
- data/lib/relaton/render/iso690/style/name_form.rb +87 -0
- data/lib/relaton/render/iso690/style/scheme.rb +33 -0
- data/lib/relaton/render/iso690/style/sort_rule.rb +25 -0
- data/lib/relaton/render/iso690/style/template_map.rb +40 -0
- data/lib/relaton/render/iso690/style/type_template.rb +45 -0
- data/lib/relaton/render/iso690/style.rb +227 -0
- data/lib/relaton/render/iso690/styles/apa.yml +45 -0
- data/lib/relaton/render/iso690/styles/author-date.yml +31 -0
- data/lib/relaton/render/iso690/styles/chicago.yml +49 -0
- data/lib/relaton/render/iso690/styles/ieee-sa.yml +45 -0
- data/lib/relaton/render/iso690/styles/jis-en.yml +68 -0
- data/lib/relaton/render/iso690/styles/jis-ja.yml +67 -0
- data/lib/relaton/render/iso690/styles/lncs.yml +54 -0
- data/lib/relaton/render/iso690/styles/mla.yml +56 -0
- data/lib/relaton/render/iso690/template.rb +248 -0
- data/lib/relaton/render/iso690.rb +28 -0
- data/lib/relaton/render/version.rb +5 -0
- data/lib/relaton/un/CLAUDE.md +1 -1
- data/lib/relaton/version.rb +1 -1
- data/lib/relaton-cli.rb +17 -0
- data/lib/relaton-render.rb +13 -0
- metadata +192 -21
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
|
3
|
+
import type { Hydration, IndexData, IndexDocument } from "./lib/types";
|
|
4
|
+
import type { DetailLoader } from "./lib/shards";
|
|
5
|
+
import {
|
|
6
|
+
applyFilters,
|
|
7
|
+
distinctValues,
|
|
8
|
+
type SortDir,
|
|
9
|
+
type SortKey,
|
|
10
|
+
} from "./lib/filter";
|
|
11
|
+
import DocumentRow from "./components/DocumentRow.vue";
|
|
12
|
+
import DocumentDetail from "./components/DocumentDetail.vue";
|
|
13
|
+
import Pagination from "./components/Pagination.vue";
|
|
14
|
+
import Icon from "./components/Icon.vue";
|
|
15
|
+
import {
|
|
16
|
+
readDocFromUrl,
|
|
17
|
+
readPageFromUrl,
|
|
18
|
+
writeDocToUrl,
|
|
19
|
+
writePageToUrl,
|
|
20
|
+
} from "./lib/url";
|
|
21
|
+
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
data: IndexData;
|
|
24
|
+
hydration?: Hydration;
|
|
25
|
+
loadDetail?: DetailLoader;
|
|
26
|
+
}>();
|
|
27
|
+
|
|
28
|
+
// Mounted without progressive loading (every existing test, and any embedder
|
|
29
|
+
// passing a complete document list) behaves exactly as before: nothing is
|
|
30
|
+
// loading, and the total is just what's in hand.
|
|
31
|
+
const STATIC_HYDRATION: Hydration = {
|
|
32
|
+
loading: false,
|
|
33
|
+
total: null,
|
|
34
|
+
requestAll: () => {},
|
|
35
|
+
};
|
|
36
|
+
const hyd = computed<Hydration>(() => props.hydration ?? STATIC_HYDRATION);
|
|
37
|
+
const loading = computed(() => hyd.value.loading);
|
|
38
|
+
|
|
39
|
+
const query = ref("");
|
|
40
|
+
const activeDoctypes = ref<Set<string>>(new Set());
|
|
41
|
+
const activeStages = ref<Set<string>>(new Set());
|
|
42
|
+
const sortKey = ref<SortKey>("id");
|
|
43
|
+
const sortDir = ref<SortDir>("asc");
|
|
44
|
+
const view = ref<"list" | "grid">(
|
|
45
|
+
(localStorage.getItem("relaton-index-view") as "list" | "grid") || "list",
|
|
46
|
+
);
|
|
47
|
+
const isDark = ref(document.documentElement.classList.contains("dark"));
|
|
48
|
+
const searchEl = ref<HTMLInputElement | null>(null);
|
|
49
|
+
|
|
50
|
+
const docs = computed(() => props.data.documents);
|
|
51
|
+
const doctypes = computed(() => distinctValues(docs.value, "doctype"));
|
|
52
|
+
const stages = computed(() => distinctValues(docs.value, "stage"));
|
|
53
|
+
// The corpus size is known from the mount node before any shard has landed, so
|
|
54
|
+
// the count line reads "12 of 166658" rather than a denominator that grows.
|
|
55
|
+
// Never report fewer than we actually hold: a shell with a missing or wrong
|
|
56
|
+
// data-total would otherwise render nonsense like "3 of 0 documents".
|
|
57
|
+
const total = computed(() =>
|
|
58
|
+
Math.max(hyd.value.total ?? 0, docs.value.length),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Detail-page routing: `?doc=<id>` selects a document to show in full instead of
|
|
62
|
+
// the list. A missing/unknown id falls back to the list. This is the second
|
|
63
|
+
// piece of URL-synced state alongside `?page=N` (see lib/url.ts).
|
|
64
|
+
const selectedId = ref<string | null>(readDocFromUrl());
|
|
65
|
+
const selectedDoc = computed(() =>
|
|
66
|
+
selectedId.value == null
|
|
67
|
+
? null
|
|
68
|
+
: (docs.value.find((d) => d.id === selectedId.value) ?? null),
|
|
69
|
+
);
|
|
70
|
+
// Opening a document — from a list row, or from a relation link on another
|
|
71
|
+
// document's detail page (detail -> detail).
|
|
72
|
+
function openDoc(id: string) {
|
|
73
|
+
selectedId.value = id;
|
|
74
|
+
// New history entry so Back retraces the path that got here.
|
|
75
|
+
writeDocToUrl(id, "push");
|
|
76
|
+
window.scrollTo({ top: 0 });
|
|
77
|
+
}
|
|
78
|
+
// Which DocIDs exist in this dataset — the set that decides whether a relation
|
|
79
|
+
// on the detail page can be rendered as a link. A `computed` is lazy, so this is
|
|
80
|
+
// only ever built when a detail panel with relations actually asks (via the
|
|
81
|
+
// `hasDoc` predicate below), never during ordinary list browsing; it then
|
|
82
|
+
// re-derives as summary shards land, so links appear as the corpus fills in.
|
|
83
|
+
const docIdSet = computed(() => new Set(docs.value.map((d) => d.id)));
|
|
84
|
+
function hasDoc(id: string): boolean {
|
|
85
|
+
return docIdSet.value.has(id);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function closeDoc() {
|
|
89
|
+
selectedId.value = null;
|
|
90
|
+
// Replace (not push) so we don't leave a forward "detail" entry that Forward
|
|
91
|
+
// would re-open. Back then steps to whatever preceded this detail — the list
|
|
92
|
+
// for a document opened from a row, or the referring document when a relation
|
|
93
|
+
// link brought us here. That mirrors the trail the reader actually walked.
|
|
94
|
+
writeDocToUrl(null, "replace");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const visible = computed(() =>
|
|
98
|
+
applyFilters(docs.value, {
|
|
99
|
+
query: query.value,
|
|
100
|
+
doctypes: activeDoctypes.value,
|
|
101
|
+
stages: activeStages.value,
|
|
102
|
+
sortKey: sortKey.value,
|
|
103
|
+
sortDir: sortDir.value,
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Client-side pagination keeps rendering fast for large indexes (e.g. BIPM's
|
|
108
|
+
// ~8k docs) — only the current page is rendered to the DOM at a time. The page
|
|
109
|
+
// size adapts to the viewport so a page fills roughly one screen instead of a
|
|
110
|
+
// fixed 100 rows. Row heights are estimates (px); grid packs two columns wide.
|
|
111
|
+
const listEl = ref<HTMLElement | null>(null);
|
|
112
|
+
const ROW_H = { list: 76, grid: 128 };
|
|
113
|
+
const RESERVE = 120; // bottom pagination + breathing room below the list
|
|
114
|
+
const MIN_ROWS = 10;
|
|
115
|
+
const MAX_ROWS = 100;
|
|
116
|
+
|
|
117
|
+
function computePageSize(): number {
|
|
118
|
+
const vh = window.innerHeight || 800;
|
|
119
|
+
const top = listEl.value?.getBoundingClientRect().top ?? 300;
|
|
120
|
+
const avail = Math.max(vh - top - RESERVE, ROW_H.list);
|
|
121
|
+
const cols = view.value === "grid" && window.innerWidth >= 640 ? 2 : 1;
|
|
122
|
+
const rows = Math.floor(avail / ROW_H[view.value]) * cols;
|
|
123
|
+
return Math.min(MAX_ROWS, Math.max(MIN_ROWS, rows));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const pageSize = ref(computePageSize());
|
|
127
|
+
// The current page is reflected in the URL (?page=N) so reload/share/bookmark
|
|
128
|
+
// preserves it; restore it here on load (clamped to a valid page after the
|
|
129
|
+
// adaptive pageSize settles — see onMounted).
|
|
130
|
+
const page = ref(readPageFromUrl());
|
|
131
|
+
const pageCount = computed(() => Math.max(1, Math.ceil(visible.value.length / pageSize.value)));
|
|
132
|
+
const paged = computed(() => {
|
|
133
|
+
const start = (page.value - 1) * pageSize.value;
|
|
134
|
+
return visible.value.slice(start, start + pageSize.value);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
function refreshPageSize() {
|
|
138
|
+
const next = computePageSize();
|
|
139
|
+
if (next !== pageSize.value) pageSize.value = next;
|
|
140
|
+
}
|
|
141
|
+
function clampPage() {
|
|
142
|
+
const clamped = Math.min(page.value, pageCount.value);
|
|
143
|
+
if (clamped !== page.value) {
|
|
144
|
+
page.value = clamped;
|
|
145
|
+
writePageToUrl(clamped, "replace");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
149
|
+
function onResize() {
|
|
150
|
+
clearTimeout(resizeTimer);
|
|
151
|
+
resizeTimer = setTimeout(refreshPageSize, 150);
|
|
152
|
+
}
|
|
153
|
+
// Grid/list density differs, so recompute when the view mode changes.
|
|
154
|
+
watch(view, () => nextTick(refreshPageSize));
|
|
155
|
+
// The list is unmounted while a detail page is open (listEl is null, so the page
|
|
156
|
+
// size can't be measured); re-measure once we return to the list. A stale/unknown
|
|
157
|
+
// `?doc=` id (whose doc isn't found) resolves to no detail — drop the param so the
|
|
158
|
+
// URL matches the list we actually show. `immediate` handles an unknown id present
|
|
159
|
+
// at load time (a deep link to a since-removed doc).
|
|
160
|
+
//
|
|
161
|
+
// While shards are still arriving, "not found" does NOT mean "removed": the
|
|
162
|
+
// document's shard may simply not have landed. Bouncing then would break every
|
|
163
|
+
// deep link on a large index, so the drop waits for loading to finish — and we
|
|
164
|
+
// hurry the load along, since someone is waiting on that exact document.
|
|
165
|
+
watch(
|
|
166
|
+
[selectedDoc, loading] as const,
|
|
167
|
+
([doc, isLoading]) => {
|
|
168
|
+
if (doc) return;
|
|
169
|
+
nextTick(refreshPageSize);
|
|
170
|
+
if (selectedId.value == null) return;
|
|
171
|
+
if (isLoading) {
|
|
172
|
+
hyd.value.requestAll();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
selectedId.value = null;
|
|
176
|
+
writeDocToUrl(null, "replace");
|
|
177
|
+
},
|
|
178
|
+
{ immediate: true },
|
|
179
|
+
);
|
|
180
|
+
// Keep the current page valid after the page size changes (e.g. on resize).
|
|
181
|
+
// Skipped while loading: pageCount over a partial corpus would clamp a URL
|
|
182
|
+
// ?page=N down to a page that only exists because the rest hasn't arrived yet.
|
|
183
|
+
watch(pageSize, () => {
|
|
184
|
+
if (!loading.value) clampPage();
|
|
185
|
+
});
|
|
186
|
+
// …so do it once, when the corpus is finally complete.
|
|
187
|
+
watch(loading, (isLoading) => {
|
|
188
|
+
if (!isLoading) clampPage();
|
|
189
|
+
});
|
|
190
|
+
// Reset to the first page whenever the filtered set changes.
|
|
191
|
+
watch([query, activeDoctypes, activeStages, sortKey, sortDir], () => {
|
|
192
|
+
if (page.value !== 1) {
|
|
193
|
+
page.value = 1;
|
|
194
|
+
writePageToUrl(1, "replace");
|
|
195
|
+
}
|
|
196
|
+
// Searching or faceting a partial corpus gives partial answers, so stop
|
|
197
|
+
// waiting for idle time and pull the rest now.
|
|
198
|
+
hyd.value.requestAll();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Detail fields live in their own shards and are fetched when a panel opens.
|
|
202
|
+
// `doc.pos` — the corpus position stamped on by the shard loader — is what
|
|
203
|
+
// addresses the detail shard. It is used in preference to the array index
|
|
204
|
+
// because a summary shard that failed to load leaves a hole, after which the
|
|
205
|
+
// two stop agreeing. indexOf remains the fallback for a document list supplied
|
|
206
|
+
// whole (no shards), where position and index are the same thing.
|
|
207
|
+
// Each document is attempted at most once; DocumentDetail guards every rich
|
|
208
|
+
// block with v-if, so the sections simply fill in on arrival.
|
|
209
|
+
const detailTried = new Set<string>();
|
|
210
|
+
function docKey(doc: IndexDocument): string {
|
|
211
|
+
return `${doc.id}|${doc.yaml ?? ""}`;
|
|
212
|
+
}
|
|
213
|
+
watch(selectedDoc, (doc) => {
|
|
214
|
+
const load = props.loadDetail;
|
|
215
|
+
if (!doc || !load) return;
|
|
216
|
+
|
|
217
|
+
const key = docKey(doc);
|
|
218
|
+
if (detailTried.has(key)) return;
|
|
219
|
+
detailTried.add(key);
|
|
220
|
+
|
|
221
|
+
const index = doc.pos ?? docs.value.indexOf(doc);
|
|
222
|
+
if (index < 0) return;
|
|
223
|
+
|
|
224
|
+
void load(index, doc.id)
|
|
225
|
+
.then((detail) => {
|
|
226
|
+
if (!detail) return;
|
|
227
|
+
const { r: _id, ...fields } = detail;
|
|
228
|
+
Object.assign(doc, fields);
|
|
229
|
+
// Relation links resolve against the loaded corpus, so a partially loaded
|
|
230
|
+
// one would show real intra-dataset targets as plain text. Same remedy as
|
|
231
|
+
// the unresolved-?doc= watcher: hurry the load rather than let "absent"
|
|
232
|
+
// read as "not in this dataset".
|
|
233
|
+
if (fields.relations?.length) props.hydration?.requestAll();
|
|
234
|
+
})
|
|
235
|
+
.catch(() => {
|
|
236
|
+
/* detail is optional enrichment; the panel already renders without it */
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
function goto(p: number) {
|
|
240
|
+
const next = Math.min(pageCount.value, Math.max(1, p));
|
|
241
|
+
if (next !== page.value) {
|
|
242
|
+
page.value = next;
|
|
243
|
+
// User navigation → a new history entry so Back/Forward walk pages.
|
|
244
|
+
writePageToUrl(next, "push");
|
|
245
|
+
}
|
|
246
|
+
document.getElementById("relaton-index-app")?.scrollIntoView({ block: "start" });
|
|
247
|
+
}
|
|
248
|
+
// Back/Forward navigation: sync both the open detail doc and the page from the
|
|
249
|
+
// URL without writing them back (which would otherwise clobber the history
|
|
250
|
+
// entry we just navigated to).
|
|
251
|
+
function onPopState() {
|
|
252
|
+
selectedId.value = readDocFromUrl();
|
|
253
|
+
const requested = Math.max(1, readPageFromUrl());
|
|
254
|
+
// Same reasoning as the pageSize watcher: don't clamp against a pageCount
|
|
255
|
+
// computed from a corpus that is still arriving.
|
|
256
|
+
page.value = loading.value
|
|
257
|
+
? requested
|
|
258
|
+
: Math.min(pageCount.value, requested);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function toggle(set: Set<string>, value: string): Set<string> {
|
|
262
|
+
const next = new Set(set);
|
|
263
|
+
next.has(value) ? next.delete(value) : next.add(value);
|
|
264
|
+
return next;
|
|
265
|
+
}
|
|
266
|
+
function toggleDoctype(v: string) {
|
|
267
|
+
activeDoctypes.value = toggle(activeDoctypes.value, v);
|
|
268
|
+
}
|
|
269
|
+
function toggleStage(v: string) {
|
|
270
|
+
activeStages.value = toggle(activeStages.value, v);
|
|
271
|
+
}
|
|
272
|
+
function clearFilters() {
|
|
273
|
+
query.value = "";
|
|
274
|
+
activeDoctypes.value = new Set();
|
|
275
|
+
activeStages.value = new Set();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function setSort(key: SortKey) {
|
|
279
|
+
if (sortKey.value === key) {
|
|
280
|
+
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
|
|
281
|
+
} else {
|
|
282
|
+
sortKey.value = key;
|
|
283
|
+
sortDir.value = "asc";
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function setView(v: "list" | "grid") {
|
|
288
|
+
view.value = v;
|
|
289
|
+
localStorage.setItem("relaton-index-view", v);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function toggleTheme() {
|
|
293
|
+
isDark.value = !isDark.value;
|
|
294
|
+
document.documentElement.classList.toggle("dark", isDark.value);
|
|
295
|
+
try {
|
|
296
|
+
localStorage.setItem("relaton-index-theme", isDark.value ? "dark" : "light");
|
|
297
|
+
} catch {
|
|
298
|
+
/* ignore */
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const hasFilters = computed(
|
|
303
|
+
() =>
|
|
304
|
+
query.value.trim() !== "" ||
|
|
305
|
+
activeDoctypes.value.size > 0 ||
|
|
306
|
+
activeStages.value.size > 0,
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
function onKey(e: KeyboardEvent) {
|
|
310
|
+
const target = e.target as HTMLElement | null;
|
|
311
|
+
const typing =
|
|
312
|
+
target &&
|
|
313
|
+
(target.tagName === "INPUT" ||
|
|
314
|
+
target.tagName === "TEXTAREA" ||
|
|
315
|
+
target.isContentEditable);
|
|
316
|
+
if (e.key === "/" && !typing) {
|
|
317
|
+
e.preventDefault();
|
|
318
|
+
searchEl.value?.focus();
|
|
319
|
+
} else if (e.key === "Escape" && target === searchEl.value) {
|
|
320
|
+
query.value = "";
|
|
321
|
+
searchEl.value?.blur();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
onMounted(() => {
|
|
325
|
+
window.addEventListener("keydown", onKey);
|
|
326
|
+
window.addEventListener("resize", onResize);
|
|
327
|
+
window.addEventListener("popstate", onPopState);
|
|
328
|
+
// Measure the real layout once rows have rendered, then size the page. Only
|
|
329
|
+
// then is pageCount final, so clamp a URL-provided page (e.g. ?page=99, or a
|
|
330
|
+
// smaller viewport with fewer pages) and normalize the URL in place.
|
|
331
|
+
nextTick(() => {
|
|
332
|
+
refreshPageSize();
|
|
333
|
+
// Only meaningful once the corpus is complete; the `loading` watcher above
|
|
334
|
+
// clamps when the last shard lands.
|
|
335
|
+
if (!loading.value) clampPage();
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
onUnmounted(() => {
|
|
339
|
+
window.removeEventListener("keydown", onKey);
|
|
340
|
+
window.removeEventListener("resize", onResize);
|
|
341
|
+
window.removeEventListener("popstate", onPopState);
|
|
342
|
+
clearTimeout(resizeTimer);
|
|
343
|
+
});
|
|
344
|
+
</script>
|
|
345
|
+
|
|
346
|
+
<template>
|
|
347
|
+
<div class="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
|
348
|
+
<header class="border-b border-slate-200 dark:border-slate-800">
|
|
349
|
+
<div class="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-5">
|
|
350
|
+
<div>
|
|
351
|
+
<h1 class="text-2xl font-semibold tracking-tight">{{ data.title }}</h1>
|
|
352
|
+
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
|
353
|
+
{{ data.description || "Please use the provided Relaton DocID to refer to an item." }}
|
|
354
|
+
</p>
|
|
355
|
+
</div>
|
|
356
|
+
<button
|
|
357
|
+
type="button"
|
|
358
|
+
class="rounded-lg border border-slate-300 p-2 text-slate-600 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
|
359
|
+
:aria-label="isDark ? 'Switch to light theme' : 'Switch to dark theme'"
|
|
360
|
+
:title="isDark ? 'Light theme' : 'Dark theme'"
|
|
361
|
+
@click="toggleTheme"
|
|
362
|
+
>
|
|
363
|
+
<Icon :name="isDark ? 'sun' : 'moon'" class="h-5 w-5" />
|
|
364
|
+
</button>
|
|
365
|
+
</div>
|
|
366
|
+
</header>
|
|
367
|
+
|
|
368
|
+
<main class="mx-auto max-w-5xl px-4 py-6">
|
|
369
|
+
<DocumentDetail
|
|
370
|
+
v-if="selectedDoc"
|
|
371
|
+
:doc="selectedDoc"
|
|
372
|
+
:has-doc="hasDoc"
|
|
373
|
+
@back="closeDoc"
|
|
374
|
+
@open="openDoc"
|
|
375
|
+
/>
|
|
376
|
+
<template v-else>
|
|
377
|
+
<!-- Toolbar -->
|
|
378
|
+
<div class="sticky top-0 z-10 -mx-4 mb-4 bg-slate-50/90 px-4 py-3 backdrop-blur dark:bg-slate-950/90">
|
|
379
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
380
|
+
<div class="relative min-w-56 flex-1">
|
|
381
|
+
<span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-slate-400">
|
|
382
|
+
<Icon name="search" class="h-4 w-4" />
|
|
383
|
+
</span>
|
|
384
|
+
<input
|
|
385
|
+
ref="searchEl"
|
|
386
|
+
v-model="query"
|
|
387
|
+
type="search"
|
|
388
|
+
class="w-full rounded-lg border border-slate-300 bg-white py-2 pl-9 pr-3 text-sm shadow-sm outline-none focus:border-brand focus:ring-2 focus:ring-brand/30 dark:border-slate-700 dark:bg-slate-900"
|
|
389
|
+
aria-label="Filter documents by identifier or title"
|
|
390
|
+
placeholder="Filter by identifier or title… ( / )"
|
|
391
|
+
/>
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
<div class="flex items-center gap-1 text-sm">
|
|
395
|
+
<span class="text-slate-500 dark:text-slate-400">Sort</span>
|
|
396
|
+
<button
|
|
397
|
+
v-for="k in (['id', 'date', 'title'] as SortKey[])"
|
|
398
|
+
:key="k"
|
|
399
|
+
type="button"
|
|
400
|
+
class="rounded-md px-2 py-1 capitalize"
|
|
401
|
+
:class="sortKey === k
|
|
402
|
+
? 'bg-brand text-white'
|
|
403
|
+
: 'text-slate-600 hover:bg-slate-200 dark:text-slate-300 dark:hover:bg-slate-800'"
|
|
404
|
+
@click="setSort(k)"
|
|
405
|
+
>
|
|
406
|
+
{{ k }}<Icon
|
|
407
|
+
v-if="sortKey === k"
|
|
408
|
+
:name="sortDir === 'asc' ? 'chevron-up' : 'chevron-down'"
|
|
409
|
+
class="ml-0.5 inline h-3.5 w-3.5"
|
|
410
|
+
/>
|
|
411
|
+
</button>
|
|
412
|
+
</div>
|
|
413
|
+
|
|
414
|
+
<div class="flex items-center gap-1 rounded-lg border border-slate-300 p-0.5 dark:border-slate-700">
|
|
415
|
+
<button
|
|
416
|
+
type="button"
|
|
417
|
+
class="rounded-md px-2 py-1 text-sm"
|
|
418
|
+
:class="view === 'list' ? 'bg-brand text-white' : 'text-slate-600 dark:text-slate-300'"
|
|
419
|
+
aria-label="List view"
|
|
420
|
+
@click="setView('list')"
|
|
421
|
+
><Icon name="list" class="h-4 w-4" /></button>
|
|
422
|
+
<button
|
|
423
|
+
type="button"
|
|
424
|
+
class="rounded-md px-2 py-1 text-sm"
|
|
425
|
+
:class="view === 'grid' ? 'bg-brand text-white' : 'text-slate-600 dark:text-slate-300'"
|
|
426
|
+
aria-label="Grid view"
|
|
427
|
+
@click="setView('grid')"
|
|
428
|
+
><Icon name="grid" class="h-4 w-4" /></button>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
|
|
432
|
+
<!-- Facets -->
|
|
433
|
+
<div v-if="doctypes.length || stages.length" class="mt-3 flex flex-wrap items-center gap-2">
|
|
434
|
+
<button
|
|
435
|
+
v-for="t in doctypes"
|
|
436
|
+
:key="'dt-' + t"
|
|
437
|
+
type="button"
|
|
438
|
+
class="rounded-full border px-3 py-1 text-xs"
|
|
439
|
+
:class="activeDoctypes.has(t)
|
|
440
|
+
? 'border-brand bg-brand text-white'
|
|
441
|
+
: 'border-slate-300 text-slate-600 hover:border-brand dark:border-slate-700 dark:text-slate-300'"
|
|
442
|
+
@click="toggleDoctype(t)"
|
|
443
|
+
>{{ t }}</button>
|
|
444
|
+
<button
|
|
445
|
+
v-for="s in stages"
|
|
446
|
+
:key="'st-' + s"
|
|
447
|
+
type="button"
|
|
448
|
+
class="rounded-full border px-3 py-1 text-xs"
|
|
449
|
+
:class="activeStages.has(s)
|
|
450
|
+
? 'border-emerald-500 bg-emerald-500 text-white'
|
|
451
|
+
: 'border-slate-300 text-slate-600 hover:border-emerald-500 dark:border-slate-700 dark:text-slate-300'"
|
|
452
|
+
@click="toggleStage(s)"
|
|
453
|
+
>{{ s }}</button>
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
<div class="mt-3 flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400">
|
|
457
|
+
<span>{{ visible.length }} of {{ total }} documents</span>
|
|
458
|
+
<span v-if="loading" class="text-slate-400 dark:text-slate-500">loading…</span>
|
|
459
|
+
<button
|
|
460
|
+
v-if="hasFilters"
|
|
461
|
+
type="button"
|
|
462
|
+
class="text-brand hover:underline dark:text-brand-dark"
|
|
463
|
+
@click="clearFilters"
|
|
464
|
+
>Clear filters</button>
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
|
|
468
|
+
<!-- Pagination (top, mirrors the bottom controls) -->
|
|
469
|
+
<Pagination class="mb-4" :page="page" :page-count="pageCount" @go="goto" />
|
|
470
|
+
|
|
471
|
+
<!-- List -->
|
|
472
|
+
<div
|
|
473
|
+
v-if="visible.length"
|
|
474
|
+
ref="listEl"
|
|
475
|
+
:class="view === 'grid'
|
|
476
|
+
? 'grid grid-cols-1 gap-3 sm:grid-cols-2'
|
|
477
|
+
: 'flex flex-col divide-y divide-slate-200 dark:divide-slate-800'"
|
|
478
|
+
>
|
|
479
|
+
<DocumentRow
|
|
480
|
+
v-for="doc in paged"
|
|
481
|
+
:key="doc.id + '|' + (doc.yaml ?? '')"
|
|
482
|
+
:doc="doc"
|
|
483
|
+
:view="view"
|
|
484
|
+
@open="openDoc"
|
|
485
|
+
/>
|
|
486
|
+
</div>
|
|
487
|
+
<p v-else class="py-16 text-center text-slate-500 dark:text-slate-400">
|
|
488
|
+
No documents match your filters.
|
|
489
|
+
</p>
|
|
490
|
+
|
|
491
|
+
<!-- Pagination (bottom) -->
|
|
492
|
+
<Pagination class="mt-6" :page="page" :page-count="pageCount" @go="goto" />
|
|
493
|
+
</template>
|
|
494
|
+
</main>
|
|
495
|
+
|
|
496
|
+
<footer class="border-t border-slate-200 py-8 text-center text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
|
497
|
+
<p>
|
|
498
|
+
An open source project by
|
|
499
|
+
<a class="text-brand hover:underline dark:text-brand-dark" href="https://www.ribose.com">Ribose</a>.
|
|
500
|
+
Built with
|
|
501
|
+
<a class="text-brand hover:underline dark:text-brand-dark" href="https://www.relaton.org">Relaton</a>.
|
|
502
|
+
</p>
|
|
503
|
+
<p v-if="data.generated" class="mt-1">Generated {{ data.generated }}</p>
|
|
504
|
+
</footer>
|
|
505
|
+
</div>
|
|
506
|
+
</template>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createApp, reactive } from "vue";
|
|
2
|
+
import App from "./App.vue";
|
|
3
|
+
import "./styles/index.css";
|
|
4
|
+
import { resolveIndex } from "./lib/hydrate";
|
|
5
|
+
import { createDetailLoader, loadSearchShards } from "./lib/shards";
|
|
6
|
+
import type { Hydration, IndexData } from "./lib/types";
|
|
7
|
+
|
|
8
|
+
const MOUNT_ID = "relaton-index-app";
|
|
9
|
+
|
|
10
|
+
// Restore the persisted theme (or fall back to the OS preference) before the
|
|
11
|
+
// app paints, so there's no light→dark flash.
|
|
12
|
+
function initTheme(): void {
|
|
13
|
+
try {
|
|
14
|
+
const stored = localStorage.getItem("relaton-index-theme");
|
|
15
|
+
const dark =
|
|
16
|
+
stored === "dark" ||
|
|
17
|
+
(stored === null &&
|
|
18
|
+
window.matchMedia?.("(prefers-color-scheme: dark)").matches);
|
|
19
|
+
document.documentElement.classList.toggle("dark", !!dark);
|
|
20
|
+
} catch {
|
|
21
|
+
/* localStorage/matchMedia may be unavailable; ignore. */
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Off the critical path, but soon: the shell has nothing to show until the
|
|
26
|
+
// first shard lands, so this is only deferred past the initial paint. The
|
|
27
|
+
// `timeout` is not optional — without it an idle callback is not guaranteed to
|
|
28
|
+
// run at all, and a backgrounded or throttled tab would sit on an empty list
|
|
29
|
+
// forever, with every escape hatch (search, facets, a deep link) requiring the
|
|
30
|
+
// user to interact with a page showing nothing.
|
|
31
|
+
const IDLE_TIMEOUT_MS = 2000;
|
|
32
|
+
|
|
33
|
+
function whenIdle(cb: () => void): void {
|
|
34
|
+
if (typeof window.requestIdleCallback === "function") {
|
|
35
|
+
window.requestIdleCallback(() => cb(), { timeout: IDLE_TIMEOUT_MS });
|
|
36
|
+
} else {
|
|
37
|
+
window.setTimeout(cb, 0);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function boot(): void {
|
|
42
|
+
const el = document.getElementById(MOUNT_ID);
|
|
43
|
+
if (!el) return;
|
|
44
|
+
|
|
45
|
+
initTheme();
|
|
46
|
+
|
|
47
|
+
const { data, shards } = resolveIndex(el);
|
|
48
|
+
// `createApp(App, props)` props are static, so both of these have to be
|
|
49
|
+
// reactive objects we mutate as shards arrive — reassigning a prop would not
|
|
50
|
+
// reach the component.
|
|
51
|
+
const state = reactive<IndexData>({ ...data });
|
|
52
|
+
|
|
53
|
+
let started = false;
|
|
54
|
+
function start(): void {
|
|
55
|
+
if (started || shards.shards <= 0) return;
|
|
56
|
+
started = true;
|
|
57
|
+
loadSearchShards(shards, (docs) => {
|
|
58
|
+
state.documents = docs;
|
|
59
|
+
})
|
|
60
|
+
.catch((err) => {
|
|
61
|
+
// Individual shard failures are already handled inside the loader; this
|
|
62
|
+
// only catches something unexpected, and must not surface as an
|
|
63
|
+
// unhandled rejection.
|
|
64
|
+
console.warn("relaton-index: shard load failed", err);
|
|
65
|
+
})
|
|
66
|
+
.finally(() => {
|
|
67
|
+
hydration.loading = false;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const hydration = reactive<Hydration>({
|
|
72
|
+
loading: shards.shards > 0,
|
|
73
|
+
total: shards.total,
|
|
74
|
+
requestAll: start,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (shards.shards > 0) whenIdle(start);
|
|
78
|
+
|
|
79
|
+
createApp(App, {
|
|
80
|
+
data: state,
|
|
81
|
+
hydration,
|
|
82
|
+
loadDetail: createDetailLoader(shards),
|
|
83
|
+
}).mount(el);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (document.readyState === "loading") {
|
|
87
|
+
document.addEventListener("DOMContentLoaded", boot, { once: true });
|
|
88
|
+
} else {
|
|
89
|
+
boot();
|
|
90
|
+
}
|