@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,144 @@
|
|
|
1
|
+
import { ref, computed } from 'vue';
|
|
2
|
+
import type { PageConfig } from './types';
|
|
3
|
+
|
|
4
|
+
export interface RuntimeSiteConfig {
|
|
5
|
+
id: string;
|
|
6
|
+
domain: string;
|
|
7
|
+
title: string;
|
|
8
|
+
subtitle?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
datasets: string[];
|
|
11
|
+
defaultDataset?: string;
|
|
12
|
+
branding?: {
|
|
13
|
+
primaryColor?: string;
|
|
14
|
+
darkColor?: string;
|
|
15
|
+
fonts?: {
|
|
16
|
+
header?: { family: string; source: string; weights?: number[]; url?: string };
|
|
17
|
+
body?: { family: string; source: string; weights?: number[]; url?: string };
|
|
18
|
+
};
|
|
19
|
+
logo?: { path: string; alt: string; url?: string };
|
|
20
|
+
footerLogo?: { path: string; alt: string; url?: string };
|
|
21
|
+
ownerName?: string;
|
|
22
|
+
ownerUrl?: string;
|
|
23
|
+
};
|
|
24
|
+
analytics?: { googleAnalyticsId?: string };
|
|
25
|
+
features?: Record<string, unknown>;
|
|
26
|
+
social?: Record<string, string>;
|
|
27
|
+
nav?: { label: string; route: string }[];
|
|
28
|
+
footerNav?: { label: string; route: string }[];
|
|
29
|
+
defaults?: { language?: string; languageOrder?: string[] };
|
|
30
|
+
email?: string;
|
|
31
|
+
pages?: PageConfig[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const siteConfig = ref<RuntimeSiteConfig | null>(null);
|
|
35
|
+
const loaded = ref(false);
|
|
36
|
+
|
|
37
|
+
function loadFont(font: { family: string; source: string; weights?: number[]; url?: string }) {
|
|
38
|
+
if (font.source === 'google') {
|
|
39
|
+
const w = (font.weights || [400, 700]).join(';');
|
|
40
|
+
const href = `https://fonts.googleapis.com/css2?family=${font.family.replace(/ /g, '+')}:wght@${w}&display=swap`;
|
|
41
|
+
const existing = document.querySelector(`link[href="${href}"]`);
|
|
42
|
+
if (existing) return;
|
|
43
|
+
const link = document.createElement('link');
|
|
44
|
+
link.rel = 'stylesheet';
|
|
45
|
+
link.href = href;
|
|
46
|
+
document.head.appendChild(link);
|
|
47
|
+
}
|
|
48
|
+
if (font.source === 'url' && font.url) {
|
|
49
|
+
const existing = document.querySelector(`link[href="${font.url}"]`);
|
|
50
|
+
if (existing) return;
|
|
51
|
+
const link = document.createElement('link');
|
|
52
|
+
link.rel = 'stylesheet';
|
|
53
|
+
link.href = font.url;
|
|
54
|
+
document.head.appendChild(link);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hexToRgb(hex: string): [number, number, number] {
|
|
59
|
+
const h = hex.replace('#', '');
|
|
60
|
+
return [
|
|
61
|
+
parseInt(h.substring(0, 2), 16),
|
|
62
|
+
parseInt(h.substring(2, 4), 16),
|
|
63
|
+
parseInt(h.substring(4, 6), 16),
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function applyBranding(config: RuntimeSiteConfig) {
|
|
68
|
+
const root = document.documentElement;
|
|
69
|
+
const b = config.branding;
|
|
70
|
+
if (!b) return;
|
|
71
|
+
|
|
72
|
+
if (b.primaryColor) {
|
|
73
|
+
root.style.setProperty('--brand-primary', b.primaryColor);
|
|
74
|
+
const [r, g, bl] = hexToRgb(b.primaryColor);
|
|
75
|
+
root.style.setProperty('--brand-primary-rgb', `${r}, ${g}, ${bl}`);
|
|
76
|
+
}
|
|
77
|
+
if (b.darkColor) {
|
|
78
|
+
root.style.setProperty('--brand-dark', b.darkColor);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (b.fonts?.header) {
|
|
82
|
+
loadFont(b.fonts.header);
|
|
83
|
+
root.style.setProperty('--font-header', `'${b.fonts.header.family}', Georgia, serif`);
|
|
84
|
+
}
|
|
85
|
+
if (b.fonts?.body) {
|
|
86
|
+
loadFont(b.fonts.body);
|
|
87
|
+
root.style.setProperty('--font-body', `'${b.fonts.body.family}', system-ui, sans-serif`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function loadConfig(): Promise<RuntimeSiteConfig | null> {
|
|
92
|
+
if (loaded.value) return siteConfig.value;
|
|
93
|
+
try {
|
|
94
|
+
const resp = await fetch('/site-config.json');
|
|
95
|
+
if (resp.ok) {
|
|
96
|
+
siteConfig.value = await resp.json();
|
|
97
|
+
if (siteConfig.value) applyBranding(siteConfig.value);
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// Non-critical
|
|
101
|
+
}
|
|
102
|
+
loaded.value = true;
|
|
103
|
+
return siteConfig.value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const BUILTIN_GLOBAL_PAGES: PageConfig[] = [
|
|
107
|
+
{ type: 'custom', route: '', title: 'Home', icon: 'home' },
|
|
108
|
+
{ type: 'custom', route: 'search', title: 'Search', icon: 'search' },
|
|
109
|
+
{ type: 'custom', route: 'graph', title: 'Graph', icon: 'graph' },
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
const BUILTIN_DATASET_PAGES: PageConfig[] = [
|
|
113
|
+
{ type: 'custom', route: '', title: 'Concepts', icon: 'list' },
|
|
114
|
+
{ type: 'stats', route: 'stats', title: 'Statistics', icon: 'chart' },
|
|
115
|
+
{ type: 'about', route: 'about', title: 'About', icon: 'info' },
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
function synthesizePages(features?: Record<string, unknown>, pages?: PageConfig[]) {
|
|
119
|
+
if (pages && pages.length > 0) return pages;
|
|
120
|
+
|
|
121
|
+
const result = [...BUILTIN_GLOBAL_PAGES];
|
|
122
|
+
if (features?.news) {
|
|
123
|
+
result.push({ type: 'news', route: 'news', title: 'News', icon: 'newspaper' });
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function useSiteConfig() {
|
|
129
|
+
const config = computed(() => siteConfig.value);
|
|
130
|
+
const visibleDatasets = computed(() => siteConfig.value?.datasets ?? []);
|
|
131
|
+
|
|
132
|
+
const globalPages = computed<PageConfig[]>(() =>
|
|
133
|
+
synthesizePages(siteConfig.value?.features, siteConfig.value?.pages)
|
|
134
|
+
.filter(p => !p.datasetScoped),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const datasetPages = computed<PageConfig[]>(() => {
|
|
138
|
+
const declared = siteConfig.value?.pages?.filter(p => p.datasetScoped) ?? [];
|
|
139
|
+
if (declared.length > 0) return declared;
|
|
140
|
+
return BUILTIN_DATASET_PAGES;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return { config, visibleDatasets, loadConfig, globalPages, datasetPages };
|
|
144
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { GraphNode, GraphEdge } from '../adapters/types';
|
|
2
|
+
import { UriRouter } from '../adapters/UriRouter';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Directed multigraph engine for concept relationships.
|
|
6
|
+
* Supports cross-register edges with stub nodes for unresolved targets.
|
|
7
|
+
*/
|
|
8
|
+
export class GraphEngine {
|
|
9
|
+
private nodes = new Map<string, GraphNode>();
|
|
10
|
+
private edges: GraphEdge[] = [];
|
|
11
|
+
private edgeKeys = new Set<string>();
|
|
12
|
+
private adjacency = new Map<string, Map<string, GraphEdge[]>>();
|
|
13
|
+
private reverseAdjacency = new Map<string, Map<string, GraphEdge[]>>();
|
|
14
|
+
|
|
15
|
+
addNode(node: GraphNode): void {
|
|
16
|
+
const existing = this.nodes.get(node.uri);
|
|
17
|
+
if (!existing) {
|
|
18
|
+
this.nodes.set(node.uri, node);
|
|
19
|
+
} else if (node.loaded && !existing.loaded) {
|
|
20
|
+
this.nodes.set(node.uri, node);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
addEdge(edge: GraphEdge): void {
|
|
25
|
+
const key = `${edge.source}\0${edge.target}\0${edge.type}`;
|
|
26
|
+
if (this.edgeKeys.has(key)) return;
|
|
27
|
+
this.edgeKeys.add(key);
|
|
28
|
+
|
|
29
|
+
const parsed = UriRouter.parseUri(edge.target);
|
|
30
|
+
if (!this.nodes.has(edge.source)) {
|
|
31
|
+
const sourceParsed = UriRouter.parseUri(edge.source);
|
|
32
|
+
this.nodes.set(edge.source, {
|
|
33
|
+
uri: edge.source,
|
|
34
|
+
register: sourceParsed?.registerId ?? edge.register,
|
|
35
|
+
conceptId: sourceParsed?.conceptId ?? '',
|
|
36
|
+
designations: {},
|
|
37
|
+
status: 'stub',
|
|
38
|
+
loaded: false,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (!this.nodes.has(edge.target)) {
|
|
42
|
+
this.nodes.set(edge.target, {
|
|
43
|
+
uri: edge.target,
|
|
44
|
+
register: parsed?.registerId ?? '',
|
|
45
|
+
conceptId: parsed?.conceptId ?? '',
|
|
46
|
+
designations: {},
|
|
47
|
+
status: 'stub',
|
|
48
|
+
loaded: false,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.edges.push(edge);
|
|
53
|
+
|
|
54
|
+
if (!this.adjacency.has(edge.source)) this.adjacency.set(edge.source, new Map());
|
|
55
|
+
const sourceAdj = this.adjacency.get(edge.source)!;
|
|
56
|
+
if (!sourceAdj.has(edge.target)) sourceAdj.set(edge.target, []);
|
|
57
|
+
sourceAdj.get(edge.target)!.push(edge);
|
|
58
|
+
|
|
59
|
+
if (!this.reverseAdjacency.has(edge.target)) this.reverseAdjacency.set(edge.target, new Map());
|
|
60
|
+
const targetAdj = this.reverseAdjacency.get(edge.target)!;
|
|
61
|
+
if (!targetAdj.has(edge.source)) targetAdj.set(edge.source, []);
|
|
62
|
+
targetAdj.get(edge.source)!.push(edge);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getNode(uri: string): GraphNode | undefined {
|
|
66
|
+
return this.nodes.get(uri);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getEdges(from?: string): GraphEdge[] {
|
|
70
|
+
if (from) {
|
|
71
|
+
const adj = this.adjacency.get(from);
|
|
72
|
+
if (!adj) return [];
|
|
73
|
+
return [...adj.values()].flat();
|
|
74
|
+
}
|
|
75
|
+
return this.edges;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getIncomingEdges(uri: string): GraphEdge[] {
|
|
79
|
+
const adj = this.reverseAdjacency.get(uri);
|
|
80
|
+
if (!adj) return [];
|
|
81
|
+
return [...adj.values()].flat();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getNeighbors(uri: string): { outgoing: string[]; incoming: string[] } {
|
|
85
|
+
const outgoing: string[] = [];
|
|
86
|
+
const adj = this.adjacency.get(uri);
|
|
87
|
+
if (adj) for (const target of adj.keys()) outgoing.push(target);
|
|
88
|
+
|
|
89
|
+
const incoming: string[] = [];
|
|
90
|
+
const radj = this.reverseAdjacency.get(uri);
|
|
91
|
+
if (radj) for (const source of radj.keys()) incoming.push(source);
|
|
92
|
+
|
|
93
|
+
return { outgoing, incoming };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getSubgraph(rootUri: string, depth: number = 2): { nodes: GraphNode[]; edges: GraphEdge[] } {
|
|
97
|
+
const visited = new Set<string>();
|
|
98
|
+
const collectedNodes: GraphNode[] = [];
|
|
99
|
+
const collectedEdges: GraphEdge[] = [];
|
|
100
|
+
const queue: { uri: string; d: number }[] = [{ uri: rootUri, d: 0 }];
|
|
101
|
+
|
|
102
|
+
while (queue.length > 0) {
|
|
103
|
+
const { uri, d } = queue.shift()!;
|
|
104
|
+
if (visited.has(uri) || d > depth) continue;
|
|
105
|
+
visited.add(uri);
|
|
106
|
+
|
|
107
|
+
const node = this.nodes.get(uri);
|
|
108
|
+
if (node) collectedNodes.push(node);
|
|
109
|
+
|
|
110
|
+
const outEdges = this.getEdges(uri);
|
|
111
|
+
for (const e of outEdges) {
|
|
112
|
+
collectedEdges.push(e);
|
|
113
|
+
if (!visited.has(e.target)) queue.push({ uri: e.target, d: d + 1 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const inEdges = this.getIncomingEdges(uri);
|
|
117
|
+
for (const e of inEdges) {
|
|
118
|
+
collectedEdges.push(e);
|
|
119
|
+
if (!visited.has(e.source)) queue.push({ uri: e.source, d: d + 1 });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { nodes: collectedNodes, edges: collectedEdges };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getAllNodes(): GraphNode[] {
|
|
127
|
+
return [...this.nodes.values()];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
get nodeCount(): number {
|
|
131
|
+
return this.nodes.size;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get edgeCount(): number {
|
|
135
|
+
return this.edges.length;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { GraphEngine } from './GraphEngine';
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createApp } from 'vue';
|
|
2
|
+
import { createPinia } from 'pinia';
|
|
3
|
+
import App from './App.vue';
|
|
4
|
+
import router from './router';
|
|
5
|
+
import 'katex/dist/katex.min.css';
|
|
6
|
+
import './style.css';
|
|
7
|
+
|
|
8
|
+
const app = createApp(App);
|
|
9
|
+
app.use(createPinia());
|
|
10
|
+
app.use(router);
|
|
11
|
+
app.mount('#app');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":"4.1.5","results":[[":__tests__/data-integrity.test.ts",{"duration":578.8590829999999,"failed":false}],[":__tests__/graph.test.ts",{"duration":3.8173329999999623,"failed":false}],[":__tests__/uri-router.test.ts",{"duration":2.3697500000000105,"failed":false}],[":__tests__/dataset-adapter.test.ts",{"duration":5.140625,"failed":false}],[":__tests__/data-integration.test.ts",{"duration":3.391625000000033,"failed":false}]]}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
|
2
|
+
|
|
3
|
+
const routes: RouteRecordRaw[] = [
|
|
4
|
+
{
|
|
5
|
+
path: '/',
|
|
6
|
+
name: 'home',
|
|
7
|
+
component: () => import('../views/HomeView.vue'),
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
path: '/dataset/:registerId',
|
|
11
|
+
name: 'dataset',
|
|
12
|
+
component: () => import('../views/DatasetView.vue'),
|
|
13
|
+
props: true,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
path: '/dataset/:registerId/concept/:conceptId',
|
|
17
|
+
name: 'concept',
|
|
18
|
+
component: () => import('../views/ConceptView.vue'),
|
|
19
|
+
props: true,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
path: '/search',
|
|
23
|
+
name: 'search',
|
|
24
|
+
component: () => import('../views/SearchView.vue'),
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
path: '/graph',
|
|
28
|
+
name: 'graph',
|
|
29
|
+
component: () => import('../views/GraphView.vue'),
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
path: '/resolve/:uri(.*)',
|
|
33
|
+
name: 'resolve',
|
|
34
|
+
component: () => import('../views/ResolveView.vue'),
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const router = createRouter({
|
|
39
|
+
history: createWebHistory(import.meta.env.BASE_URL),
|
|
40
|
+
routes,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export default router;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { RouteRecordRaw } from 'vue-router';
|
|
2
|
+
import type { PageConfig } from '../config/types';
|
|
3
|
+
|
|
4
|
+
const pageComponents: Record<string, () => Promise<any>> = {
|
|
5
|
+
news: () => import('../views/NewsView.vue'),
|
|
6
|
+
contributors: () => import('../views/ContributorsView.vue'),
|
|
7
|
+
about: () => import('../views/AboutView.vue'),
|
|
8
|
+
stats: () => import('../views/StatsView.vue'),
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function buildPageRoutes(pages: PageConfig[]): RouteRecordRaw[] {
|
|
12
|
+
const routes: RouteRecordRaw[] = [];
|
|
13
|
+
|
|
14
|
+
for (const page of pages) {
|
|
15
|
+
const component = pageComponents[page.type];
|
|
16
|
+
if (!component) continue;
|
|
17
|
+
|
|
18
|
+
if (page.datasetScoped) {
|
|
19
|
+
routes.push({
|
|
20
|
+
path: `/dataset/:registerId/${page.route}`,
|
|
21
|
+
name: page.route,
|
|
22
|
+
component,
|
|
23
|
+
props: true,
|
|
24
|
+
});
|
|
25
|
+
} else {
|
|
26
|
+
routes.push({
|
|
27
|
+
path: `/${page.route}`,
|
|
28
|
+
name: page.route,
|
|
29
|
+
component,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return routes;
|
|
35
|
+
}
|
package/src/stores/ui.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { defineStore } from 'pinia';
|
|
2
|
+
import { ref, computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
export type Theme = 'light' | 'dark' | 'system';
|
|
5
|
+
|
|
6
|
+
function getSystemDark(): boolean {
|
|
7
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function getStoredTheme(): Theme {
|
|
11
|
+
return (localStorage.getItem('theme') as Theme) || 'system';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const useUiStore = defineStore('ui', () => {
|
|
15
|
+
const sidebarOpen = ref(false);
|
|
16
|
+
const selectedLang = ref('eng');
|
|
17
|
+
const searchQuery = ref('');
|
|
18
|
+
const showGraphPanel = ref(false);
|
|
19
|
+
const themePref = ref<Theme>(getStoredTheme());
|
|
20
|
+
|
|
21
|
+
const isDark = computed(() => {
|
|
22
|
+
return themePref.value === 'dark' || (themePref.value === 'system' && getSystemDark());
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function applyTheme() {
|
|
26
|
+
document.documentElement.classList.toggle('dark', isDark.value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setTheme(t: Theme) {
|
|
30
|
+
themePref.value = t;
|
|
31
|
+
localStorage.setItem('theme', t);
|
|
32
|
+
applyTheme();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toggleTheme() {
|
|
36
|
+
if (isDark.value) {
|
|
37
|
+
setTheme('light');
|
|
38
|
+
} else {
|
|
39
|
+
setTheme('dark');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toggleSidebar() {
|
|
44
|
+
sidebarOpen.value = !sidebarOpen.value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function setLang(lang: string) {
|
|
48
|
+
selectedLang.value = lang;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Apply on creation
|
|
52
|
+
applyTheme();
|
|
53
|
+
// React to system preference changes
|
|
54
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
55
|
+
if (themePref.value === 'system') applyTheme();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return { sidebarOpen, selectedLang, searchQuery, showGraphPanel, themePref, isDark, toggleSidebar, setLang, setTheme, toggleTheme };
|
|
59
|
+
});
|