picocrank 1.24.0 → 1.25.1
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/AGENTS.md +2 -0
- package/docs/filter-query-v1.md +180 -0
- package/logo.svg +7 -7
- package/package.json +7 -5
- package/public/themes/README.md +34 -2
- package/public/themes/index.json +1 -5
- package/styles/dark-theme.css +42 -186
- package/styles/picocrank-extensions.css +15 -0
- package/styles.css +1 -0
- package/supplemental-themes/README.md +9 -0
- package/supplemental-themes/catppuccin-latte-frappe/theme.css +211 -0
- package/supplemental-themes/dracula-alucard/theme.css +193 -0
- package/supplemental-themes/gruvbox-dark-light/theme.css +199 -0
- package/supplemental-themes/index.json +17 -0
- package/{public/themes → supplemental-themes}/waffles/theme.css +69 -46
- package/vite.config.js +91 -0
- package/vue/components/Header.vue +3 -3
- package/vue/components/Login.vue +1 -3
- package/vue/components/Section.vue +45 -14
- package/vue/components/Sidebar.vue +6 -11
- package/vue/components/SidebarNavLink.vue +6 -9
- package/vue/components/Table.vue +1115 -75
- package/vue/components/TableColumnFilterPopover.vue +263 -44
- package/vue/components/TableColumnOptionsPopover.vue +1116 -0
- package/vue/components/Tabs.vue +4 -25
- package/vue/components/ThemeSwitcher.vue +158 -0
- package/vue/components/TopBar.vue +2 -2
- package/vue/composables/tableColumnPresets.js +296 -0
- package/vue/composables/tableFilterQuery.js +202 -0
- package/vue/composables/tableFilters.js +167 -3
- package/vue/composables/useCustomTheme.js +167 -24
- package/vue/data/examplePeople.js +2 -6
- package/vue/examples/App.vue +14 -1
- package/vue/examples/ExampleHeaderThemeSwitcher.vue +78 -0
- package/vue/examples/LoginExample.vue +1 -3
- package/vue/examples/PageStructure.vue +17 -0
- package/vue/examples/TableExample.vue +329 -11
- package/vue/examples/TableRemoteExample.vue +112 -4
- package/vue/examples/ThemePreview.vue +45 -38
- package/public/themes/catppuccin/theme.css +0 -195
- package/public/themes/dracula/theme.css +0 -181
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyColumnFilter,
|
|
3
|
+
filterEntrySpec,
|
|
4
|
+
isFilterActive,
|
|
5
|
+
isFilterEntryActive,
|
|
6
|
+
normalizeColumnFilterEntries,
|
|
7
|
+
normalizeFilter,
|
|
8
|
+
} from './tableFilters.js'
|
|
9
|
+
|
|
10
|
+
export const FILTER_QUERY_VERSION = 1
|
|
11
|
+
|
|
12
|
+
/** @see docs/filter-query-v1.md */
|
|
13
|
+
|
|
14
|
+
export function createEmptyFilterQuery() {
|
|
15
|
+
return {
|
|
16
|
+
version: FILTER_QUERY_VERSION,
|
|
17
|
+
match: 'all',
|
|
18
|
+
columns: [],
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeFilterSpec(entry, header, rows) {
|
|
23
|
+
if (!entry || typeof entry !== 'object') {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { id, name, enabled, ...partial } = entry
|
|
28
|
+
const filter = normalizeFilter(header, partial, rows)
|
|
29
|
+
if (!filter) {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
id: typeof id === 'string' ? id : undefined,
|
|
35
|
+
name: typeof name === 'string' ? name : '',
|
|
36
|
+
enabled: enabled !== false,
|
|
37
|
+
...filter,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function normalizeFilterQuery(filterQuery, headers = [], rows = []) {
|
|
42
|
+
if (!filterQuery || typeof filterQuery !== 'object') {
|
|
43
|
+
return createEmptyFilterQuery()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const headerByKey = new Map(
|
|
47
|
+
headers
|
|
48
|
+
.filter((header) => header?.key)
|
|
49
|
+
.map((header) => [header.key, header]),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const columns = Array.isArray(filterQuery.columns)
|
|
53
|
+
? filterQuery.columns
|
|
54
|
+
.map((column) => {
|
|
55
|
+
if (!column?.key || !headerByKey.has(column.key)) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const header = headerByKey.get(column.key)
|
|
60
|
+
if (header.filterable === false) {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const filters = Array.isArray(column.filters)
|
|
65
|
+
? column.filters
|
|
66
|
+
.map((entry) => normalizeFilterSpec(entry, header, rows))
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
: []
|
|
69
|
+
|
|
70
|
+
if (filters.length === 0) {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
key: column.key,
|
|
76
|
+
match: column.match === 'any' ? 'any' : 'all',
|
|
77
|
+
filters,
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
: []
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
version: FILTER_QUERY_VERSION,
|
|
85
|
+
match: filterQuery.match === 'any' ? 'any' : 'all',
|
|
86
|
+
columns,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function buildFilterQuery(filters = {}, headers = [], rows = [], { includeInactive = true } = {}) {
|
|
91
|
+
const headerByKey = new Map(
|
|
92
|
+
headers
|
|
93
|
+
.filter((header) => header?.key)
|
|
94
|
+
.map((header) => [header.key, header]),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const columns = []
|
|
98
|
+
|
|
99
|
+
for (const [key, columnFilters] of Object.entries(filters ?? {})) {
|
|
100
|
+
const header = headerByKey.get(key)
|
|
101
|
+
if (!header || header.filterable === false) {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const entries = normalizeColumnFilterEntries(columnFilters, header, rows)
|
|
106
|
+
.map((entry) => normalizeFilterSpec(entry, header, rows))
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.filter((entry) => includeInactive || isFilterEntryActive(entry))
|
|
109
|
+
|
|
110
|
+
if (entries.length === 0) {
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
columns.push({
|
|
115
|
+
key,
|
|
116
|
+
match: 'all',
|
|
117
|
+
filters: entries,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
version: FILTER_QUERY_VERSION,
|
|
123
|
+
match: 'all',
|
|
124
|
+
columns,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function filterQueryToFilters(filterQuery, headers = [], rows = []) {
|
|
129
|
+
const normalized = normalizeFilterQuery(filterQuery, headers, rows)
|
|
130
|
+
const filters = {}
|
|
131
|
+
|
|
132
|
+
for (const column of normalized.columns) {
|
|
133
|
+
filters[column.key] = column.filters.map((entry) => ({ ...entry }))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return filters
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function applyColumnFilterQuery(row, header, columnQuery) {
|
|
140
|
+
const activeFilters = (columnQuery.filters ?? [])
|
|
141
|
+
.filter((entry) => entry.enabled !== false)
|
|
142
|
+
.map((entry) => filterEntrySpec(entry))
|
|
143
|
+
.filter((filter) => isFilterActive(filter))
|
|
144
|
+
|
|
145
|
+
if (activeFilters.length === 0) {
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (columnQuery.match === 'any') {
|
|
150
|
+
return activeFilters.some((filter) => applyColumnFilter(row, header, filter))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return activeFilters.every((filter) => applyColumnFilter(row, header, filter))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function applyFilterQuery(rows, headers = [], filterQuery) {
|
|
157
|
+
const normalized = normalizeFilterQuery(filterQuery, headers, rows)
|
|
158
|
+
if (normalized.columns.length === 0) {
|
|
159
|
+
return rows
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const headerByKey = new Map(
|
|
163
|
+
headers
|
|
164
|
+
.filter((header) => header?.key)
|
|
165
|
+
.map((header) => [header.key, header]),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return rows.filter((row) => {
|
|
169
|
+
const columnResults = normalized.columns.map((column) => {
|
|
170
|
+
const header = headerByKey.get(column.key)
|
|
171
|
+
if (!header) {
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
return applyColumnFilterQuery(row, header, column)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
if (normalized.match === 'any') {
|
|
178
|
+
return columnResults.some(Boolean)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return columnResults.every(Boolean)
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function countActiveFilterQuery(filterQuery, headers = [], rows = []) {
|
|
186
|
+
const normalized = normalizeFilterQuery(filterQuery, headers, rows)
|
|
187
|
+
let total = 0
|
|
188
|
+
|
|
189
|
+
for (const column of normalized.columns) {
|
|
190
|
+
total += column.filters.filter((entry) => isFilterEntryActive(entry)).length
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return total
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function isFilterQueryActive(filterQuery, headers = [], rows = []) {
|
|
197
|
+
return countActiveFilterQuery(filterQuery, headers, rows) > 0
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function cloneFilterQuery(filterQuery) {
|
|
201
|
+
return JSON.parse(JSON.stringify(filterQuery ?? createEmptyFilterQuery()))
|
|
202
|
+
}
|
|
@@ -1,7 +1,37 @@
|
|
|
1
1
|
const TEXT_OPERATORS = ['contains', 'equals', 'startsWith', 'empty']
|
|
2
2
|
const NUMBER_OPERATORS = ['eq', 'gt', 'gte', 'lt', 'lte']
|
|
3
3
|
|
|
4
|
+
const TEXT_OPERATOR_LABELS = {
|
|
5
|
+
contains: 'Contains',
|
|
6
|
+
equals: 'Equals',
|
|
7
|
+
startsWith: 'Starts with',
|
|
8
|
+
empty: 'Is empty',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const NUMBER_OPERATOR_LABELS = {
|
|
12
|
+
eq: 'Equals',
|
|
13
|
+
gt: 'Greater than',
|
|
14
|
+
gte: 'Greater or equal',
|
|
15
|
+
lt: 'Less than',
|
|
16
|
+
lte: 'Less or equal',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let filterIdCounter = 0
|
|
20
|
+
|
|
21
|
+
export function createFilterId() {
|
|
22
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
23
|
+
return `filter-${crypto.randomUUID()}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
filterIdCounter += 1
|
|
27
|
+
return `filter-${filterIdCounter}-${Date.now()}`
|
|
28
|
+
}
|
|
29
|
+
|
|
4
30
|
export function inferFilterType(header, rows = []) {
|
|
31
|
+
if (!header) {
|
|
32
|
+
return 'text'
|
|
33
|
+
}
|
|
34
|
+
|
|
5
35
|
if (header.filterType) {
|
|
6
36
|
return header.filterType
|
|
7
37
|
}
|
|
@@ -85,6 +115,101 @@ export function normalizeFilter(header, partial, rows = []) {
|
|
|
85
115
|
}
|
|
86
116
|
}
|
|
87
117
|
|
|
118
|
+
export function filterEntrySpec(entry) {
|
|
119
|
+
if (!entry || typeof entry !== 'object') {
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { id, name, enabled, ...filter } = entry
|
|
124
|
+
return filter
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function normalizeStoredFilterEntry(header, entry, rows = []) {
|
|
128
|
+
if (!entry || typeof entry !== 'object') {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { id, name, enabled, ...partial } = entry
|
|
133
|
+
const filter = normalizeFilter(header, partial, rows)
|
|
134
|
+
if (!filter) {
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
id: id || createFilterId(),
|
|
140
|
+
name: typeof name === 'string' ? name : '',
|
|
141
|
+
enabled: enabled !== false,
|
|
142
|
+
...filter,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function normalizeColumnFilterEntries(value, header = null, rows = []) {
|
|
147
|
+
if (!value) {
|
|
148
|
+
return []
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (Array.isArray(value)) {
|
|
152
|
+
return value
|
|
153
|
+
.map((entry) => normalizeStoredFilterEntry(header, entry, rows))
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (typeof value === 'object' && value.type) {
|
|
158
|
+
const filter = header ? normalizeFilter(header, value, rows) : value
|
|
159
|
+
if (!filter || !isFilterActive(filter)) {
|
|
160
|
+
return []
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return [{
|
|
164
|
+
id: value.id || createFilterId(),
|
|
165
|
+
name: typeof value.name === 'string' ? value.name : '',
|
|
166
|
+
enabled: value.enabled !== false,
|
|
167
|
+
...filter,
|
|
168
|
+
}]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return []
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function createFilterEntry(header, partial, rows = [], { name = '' } = {}) {
|
|
175
|
+
const filter = normalizeFilter(header, partial, rows)
|
|
176
|
+
if (!filter) {
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
id: createFilterId(),
|
|
182
|
+
name: typeof name === 'string' ? name.trim() : '',
|
|
183
|
+
enabled: true,
|
|
184
|
+
...filter,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function describeFilter(header, entry) {
|
|
189
|
+
const filter = filterEntrySpec(entry)
|
|
190
|
+
if (!filter) {
|
|
191
|
+
return 'Filter'
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const label = header?.label || header?.key || 'Column'
|
|
195
|
+
|
|
196
|
+
switch (filter.type) {
|
|
197
|
+
case 'text':
|
|
198
|
+
if (filter.operator === 'empty') {
|
|
199
|
+
return `${label} is empty`
|
|
200
|
+
}
|
|
201
|
+
return `${TEXT_OPERATOR_LABELS[filter.operator] || 'Contains'} “${filter.value}”`
|
|
202
|
+
case 'number':
|
|
203
|
+
return `${NUMBER_OPERATOR_LABELS[filter.operator] || 'Equals'} ${filter.value}`
|
|
204
|
+
case 'boolean':
|
|
205
|
+
return filter.value ? 'Yes' : 'No'
|
|
206
|
+
case 'select':
|
|
207
|
+
return `${filter.values.length} selected`
|
|
208
|
+
default:
|
|
209
|
+
return 'Filter'
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
88
213
|
export function isFilterActive(filter) {
|
|
89
214
|
if (!filter) {
|
|
90
215
|
return false
|
|
@@ -104,6 +229,29 @@ export function isFilterActive(filter) {
|
|
|
104
229
|
}
|
|
105
230
|
}
|
|
106
231
|
|
|
232
|
+
export function isFilterEntryActive(entry) {
|
|
233
|
+
return entry?.enabled !== false && isFilterActive(filterEntrySpec(entry))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function isColumnFiltered(columnValue, header = null, rows = []) {
|
|
237
|
+
return normalizeColumnFilterEntries(columnValue, header, rows)
|
|
238
|
+
.some((entry) => isFilterEntryActive(entry))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function countActiveFilters(filters = {}, headers = [], rows = []) {
|
|
242
|
+
const headerByKey = new Map(headers.map((header) => [header.key, header]))
|
|
243
|
+
let total = 0
|
|
244
|
+
|
|
245
|
+
for (const [key, value] of Object.entries(filters ?? {})) {
|
|
246
|
+
const header = headerByKey.get(key) ?? { key }
|
|
247
|
+
total += normalizeColumnFilterEntries(value, header, rows)
|
|
248
|
+
.filter((entry) => isFilterEntryActive(entry))
|
|
249
|
+
.length
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return total
|
|
253
|
+
}
|
|
254
|
+
|
|
107
255
|
function compareText(value, filter) {
|
|
108
256
|
const text = value == null ? '' : String(value)
|
|
109
257
|
const needle = String(filter.value ?? '')
|
|
@@ -166,6 +314,18 @@ export function applyColumnFilter(row, header, filter) {
|
|
|
166
314
|
}
|
|
167
315
|
}
|
|
168
316
|
|
|
317
|
+
export function applyColumnFilterEntries(row, header, entries) {
|
|
318
|
+
const activeFilters = normalizeColumnFilterEntries(entries, header)
|
|
319
|
+
.filter((entry) => isFilterEntryActive(entry))
|
|
320
|
+
.map((entry) => filterEntrySpec(entry))
|
|
321
|
+
|
|
322
|
+
if (activeFilters.length === 0) {
|
|
323
|
+
return true
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return activeFilters.every((filter) => applyColumnFilter(row, header, filter))
|
|
327
|
+
}
|
|
328
|
+
|
|
169
329
|
export function applyFilters(rows, headers, filters) {
|
|
170
330
|
if (!filters || Object.keys(filters).length === 0) {
|
|
171
331
|
return rows
|
|
@@ -174,12 +334,12 @@ export function applyFilters(rows, headers, filters) {
|
|
|
174
334
|
const headerByKey = new Map(headers.map((header) => [header.key, header]))
|
|
175
335
|
|
|
176
336
|
return rows.filter((row) => {
|
|
177
|
-
for (const [key,
|
|
337
|
+
for (const [key, columnFilters] of Object.entries(filters)) {
|
|
178
338
|
const header = headerByKey.get(key)
|
|
179
|
-
if (!header
|
|
339
|
+
if (!header) {
|
|
180
340
|
continue
|
|
181
341
|
}
|
|
182
|
-
if (!
|
|
342
|
+
if (!applyColumnFilterEntries(row, header, columnFilters)) {
|
|
183
343
|
return false
|
|
184
344
|
}
|
|
185
345
|
}
|
|
@@ -223,6 +383,10 @@ export function cloneFilters(filters = {}) {
|
|
|
223
383
|
return JSON.parse(JSON.stringify(filters ?? {}))
|
|
224
384
|
}
|
|
225
385
|
|
|
386
|
+
export function cloneColumnFilterEntries(value, header = null, rows = []) {
|
|
387
|
+
return normalizeColumnFilterEntries(value, header, rows).map((entry) => ({ ...entry }))
|
|
388
|
+
}
|
|
389
|
+
|
|
226
390
|
export function sortRows(rows, sortBy, sortDir) {
|
|
227
391
|
if (!sortBy) {
|
|
228
392
|
return [...rows]
|
|
@@ -3,30 +3,92 @@ import { ref, watch } from 'vue'
|
|
|
3
3
|
const STYLE_ELEMENT_ID = 'theme-style'
|
|
4
4
|
const DEFAULT_STORAGE_KEY = 'picocrank-custom-theme'
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
export const SUPPLEMENTAL_THEME_NAMES = [
|
|
7
|
+
'catppuccin-latte-frappe',
|
|
8
|
+
'dracula-alucard',
|
|
9
|
+
'gruvbox-dark-light',
|
|
10
|
+
'waffles',
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const LEGACY_THEME_ALIASES = {
|
|
14
|
+
catppuccin: 'catppuccin-latte-frappe',
|
|
15
|
+
dracula: 'dracula-alucard',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveDefaultBasePath(segment) {
|
|
7
19
|
try {
|
|
8
20
|
const base = import.meta.env?.BASE_URL || '/'
|
|
9
|
-
const joined = base.endsWith('/') ? `${base}
|
|
10
|
-
return joined.replace(/\/$/, '') ||
|
|
21
|
+
const joined = base.endsWith('/') ? `${base}${segment}` : `${base}/${segment}`
|
|
22
|
+
return joined.replace(/\/$/, '') || `/${segment}`
|
|
11
23
|
} catch {
|
|
12
|
-
return
|
|
24
|
+
return `/${segment}`
|
|
13
25
|
}
|
|
14
26
|
}
|
|
15
27
|
|
|
16
|
-
const
|
|
28
|
+
const DEFAULT_THEMES_BASE_PATH = resolveDefaultBasePath('themes')
|
|
29
|
+
const DEFAULT_SUPPLEMENTAL_THEMES_BASE_PATH = resolveDefaultBasePath('supplemental-themes')
|
|
17
30
|
|
|
18
31
|
const availableThemes = ref([])
|
|
32
|
+
const themeLabels = ref({})
|
|
19
33
|
const themePreference = ref('')
|
|
20
|
-
const themesBasePath = ref(
|
|
34
|
+
const themesBasePath = ref(DEFAULT_THEMES_BASE_PATH)
|
|
35
|
+
const supplementalThemesBasePath = ref(DEFAULT_SUPPLEMENTAL_THEMES_BASE_PATH)
|
|
21
36
|
const storageKey = ref(DEFAULT_STORAGE_KEY)
|
|
37
|
+
const includeSupplementalThemes = ref(false)
|
|
38
|
+
const supplementalThemeNames = ref([...SUPPLEMENTAL_THEME_NAMES])
|
|
22
39
|
let initialized = false
|
|
23
40
|
|
|
24
|
-
function normalizeBasePath(basePath) {
|
|
41
|
+
function normalizeBasePath(basePath, fallback) {
|
|
25
42
|
if (!basePath || typeof basePath !== 'string') {
|
|
26
|
-
return
|
|
43
|
+
return fallback
|
|
27
44
|
}
|
|
28
45
|
const trimmed = basePath.replace(/\/$/, '')
|
|
29
|
-
return trimmed ||
|
|
46
|
+
return trimmed || fallback
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeStoredThemeName(name) {
|
|
50
|
+
if (!name) {
|
|
51
|
+
return ''
|
|
52
|
+
}
|
|
53
|
+
return LEGACY_THEME_ALIASES[name] || name
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeThemeEntry(entry) {
|
|
57
|
+
if (typeof entry === 'string' && entry.length > 0) {
|
|
58
|
+
return { id: entry, label: entry }
|
|
59
|
+
}
|
|
60
|
+
if (entry && typeof entry.id === 'string' && entry.id.length > 0) {
|
|
61
|
+
return {
|
|
62
|
+
id: entry.id,
|
|
63
|
+
label: typeof entry.label === 'string' && entry.label.length > 0 ? entry.label : entry.id,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function mergeThemeEntries(entries) {
|
|
70
|
+
const ids = []
|
|
71
|
+
const labels = {}
|
|
72
|
+
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
const normalized = normalizeThemeEntry(entry)
|
|
75
|
+
if (!normalized) {
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
ids.push(normalized.id)
|
|
79
|
+
if (normalized.label !== normalized.id) {
|
|
80
|
+
labels[normalized.id] = normalized.label
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
ids: [...new Set(ids)].sort((a, b) => a.localeCompare(b)),
|
|
86
|
+
labels,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isSupplementalThemeName(name) {
|
|
91
|
+
return supplementalThemeNames.value.includes(name)
|
|
30
92
|
}
|
|
31
93
|
|
|
32
94
|
function readStoredTheme(key) {
|
|
@@ -36,6 +98,10 @@ function readStoredTheme(key) {
|
|
|
36
98
|
return localStorage.getItem(key) || ''
|
|
37
99
|
}
|
|
38
100
|
|
|
101
|
+
function readStoredThemePreference(key) {
|
|
102
|
+
return normalizeStoredThemeName(readStoredTheme(key))
|
|
103
|
+
}
|
|
104
|
+
|
|
39
105
|
function persistTheme(key, value) {
|
|
40
106
|
if (typeof localStorage === 'undefined') {
|
|
41
107
|
return
|
|
@@ -61,6 +127,18 @@ function ensureStyleElement() {
|
|
|
61
127
|
return themeStyle
|
|
62
128
|
}
|
|
63
129
|
|
|
130
|
+
function isSupplementalTheme(name) {
|
|
131
|
+
return includeSupplementalThemes.value
|
|
132
|
+
&& isSupplementalThemeName(name)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function themeStylesheetHref(name) {
|
|
136
|
+
const basePath = isSupplementalThemeName(name)
|
|
137
|
+
? supplementalThemesBasePath.value
|
|
138
|
+
: themesBasePath.value
|
|
139
|
+
return `${basePath}/${encodeURIComponent(name)}/theme.css`
|
|
140
|
+
}
|
|
141
|
+
|
|
64
142
|
function applyTheme() {
|
|
65
143
|
const themeStyle = ensureStyleElement()
|
|
66
144
|
if (!themeStyle) {
|
|
@@ -73,38 +151,74 @@ function applyTheme() {
|
|
|
73
151
|
return
|
|
74
152
|
}
|
|
75
153
|
|
|
76
|
-
|
|
154
|
+
if (isSupplementalThemeName(name) && !includeSupplementalThemes.value) {
|
|
155
|
+
themeStyle.textContent = ''
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const href = themeStylesheetHref(name)
|
|
77
160
|
themeStyle.textContent = `@import url('${href}') layer(theme);`
|
|
78
161
|
}
|
|
79
162
|
|
|
80
163
|
function setTheme(name) {
|
|
81
|
-
|
|
164
|
+
const nextName = normalizeStoredThemeName(name || '')
|
|
165
|
+
if (nextName && isSupplementalThemeName(nextName) && !includeSupplementalThemes.value) {
|
|
166
|
+
themePreference.value = ''
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
themePreference.value = nextName
|
|
82
170
|
}
|
|
83
171
|
|
|
84
172
|
function clearTheme() {
|
|
85
173
|
setTheme('')
|
|
86
174
|
}
|
|
87
175
|
|
|
88
|
-
|
|
176
|
+
function clearStoredSupplementalThemeIfDisabled() {
|
|
177
|
+
if (
|
|
178
|
+
themePreference.value
|
|
179
|
+
&& isSupplementalThemeName(themePreference.value)
|
|
180
|
+
&& !includeSupplementalThemes.value
|
|
181
|
+
) {
|
|
182
|
+
themePreference.value = ''
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function fetchThemeIndex(basePath) {
|
|
89
187
|
if (typeof fetch === 'undefined') {
|
|
90
|
-
|
|
91
|
-
return availableThemes.value
|
|
188
|
+
return []
|
|
92
189
|
}
|
|
93
190
|
|
|
94
|
-
const indexUrl = `${
|
|
191
|
+
const indexUrl = `${basePath}/index.json`
|
|
95
192
|
try {
|
|
96
193
|
const response = await fetch(indexUrl, { cache: 'no-store' })
|
|
97
194
|
if (!response.ok) {
|
|
98
|
-
|
|
99
|
-
return availableThemes.value
|
|
195
|
+
return []
|
|
100
196
|
}
|
|
101
197
|
const data = await response.json()
|
|
102
|
-
|
|
103
|
-
? data.themes.filter((theme) => typeof theme === 'string' && theme.length > 0)
|
|
104
|
-
: []
|
|
105
|
-
availableThemes.value = themes.sort((a, b) => a.localeCompare(b))
|
|
198
|
+
return Array.isArray(data?.themes) ? data.themes : []
|
|
106
199
|
} catch {
|
|
107
|
-
|
|
200
|
+
return []
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function discoverThemes() {
|
|
205
|
+
const userThemes = mergeThemeEntries(await fetchThemeIndex(themesBasePath.value))
|
|
206
|
+
let ids = [...userThemes.ids]
|
|
207
|
+
let labels = { ...userThemes.labels }
|
|
208
|
+
|
|
209
|
+
if (includeSupplementalThemes.value) {
|
|
210
|
+
const supplementalThemes = mergeThemeEntries(await fetchThemeIndex(supplementalThemesBasePath.value))
|
|
211
|
+
ids = [...ids, ...supplementalThemes.ids]
|
|
212
|
+
labels = { ...labels, ...supplementalThemes.labels }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
availableThemes.value = [...new Set(ids)].sort((a, b) => a.localeCompare(b))
|
|
216
|
+
themeLabels.value = labels
|
|
217
|
+
|
|
218
|
+
clearStoredSupplementalThemeIfDisabled()
|
|
219
|
+
|
|
220
|
+
if (themePreference.value && !availableThemes.value.includes(themePreference.value)) {
|
|
221
|
+
themePreference.value = ''
|
|
108
222
|
}
|
|
109
223
|
|
|
110
224
|
return availableThemes.value
|
|
@@ -112,11 +226,24 @@ async function discoverThemes() {
|
|
|
112
226
|
|
|
113
227
|
function configure(options = {}) {
|
|
114
228
|
if (options.themesBasePath !== undefined) {
|
|
115
|
-
themesBasePath.value = normalizeBasePath(options.themesBasePath)
|
|
229
|
+
themesBasePath.value = normalizeBasePath(options.themesBasePath, DEFAULT_THEMES_BASE_PATH)
|
|
230
|
+
}
|
|
231
|
+
if (options.supplementalThemesBasePath !== undefined) {
|
|
232
|
+
supplementalThemesBasePath.value = normalizeBasePath(
|
|
233
|
+
options.supplementalThemesBasePath,
|
|
234
|
+
DEFAULT_SUPPLEMENTAL_THEMES_BASE_PATH,
|
|
235
|
+
)
|
|
116
236
|
}
|
|
117
237
|
if (options.storageKey !== undefined && typeof options.storageKey === 'string' && options.storageKey) {
|
|
118
238
|
storageKey.value = options.storageKey
|
|
119
239
|
}
|
|
240
|
+
if (options.includeSupplementalThemes !== undefined) {
|
|
241
|
+
includeSupplementalThemes.value = Boolean(options.includeSupplementalThemes)
|
|
242
|
+
}
|
|
243
|
+
if (Array.isArray(options.supplementalThemeNames)) {
|
|
244
|
+
supplementalThemeNames.value = options.supplementalThemeNames
|
|
245
|
+
.filter((theme) => typeof theme === 'string' && theme.length > 0)
|
|
246
|
+
}
|
|
120
247
|
if (Array.isArray(options.availableThemes)) {
|
|
121
248
|
availableThemes.value = options.availableThemes
|
|
122
249
|
.filter((theme) => typeof theme === 'string' && theme.length > 0)
|
|
@@ -128,11 +255,21 @@ function initCustomTheme(options = {}) {
|
|
|
128
255
|
configure(options)
|
|
129
256
|
|
|
130
257
|
if (!initialized) {
|
|
131
|
-
|
|
258
|
+
const storedTheme = readStoredThemePreference(storageKey.value)
|
|
259
|
+
if (storedTheme !== readStoredTheme(storageKey.value)) {
|
|
260
|
+
persistTheme(storageKey.value, storedTheme)
|
|
261
|
+
}
|
|
262
|
+
themePreference.value = storedTheme
|
|
263
|
+
clearStoredSupplementalThemeIfDisabled()
|
|
132
264
|
watch(themePreference, (value) => {
|
|
133
265
|
persistTheme(storageKey.value, value)
|
|
134
266
|
applyTheme()
|
|
135
267
|
})
|
|
268
|
+
watch(includeSupplementalThemes, () => {
|
|
269
|
+
void discoverThemes()
|
|
270
|
+
clearStoredSupplementalThemeIfDisabled()
|
|
271
|
+
applyTheme()
|
|
272
|
+
})
|
|
136
273
|
initialized = true
|
|
137
274
|
}
|
|
138
275
|
|
|
@@ -147,15 +284,21 @@ export function useCustomTheme(options) {
|
|
|
147
284
|
|
|
148
285
|
return {
|
|
149
286
|
availableThemes,
|
|
287
|
+
themeLabels,
|
|
150
288
|
themePreference,
|
|
151
289
|
themesBasePath,
|
|
290
|
+
supplementalThemesBasePath,
|
|
152
291
|
storageKey,
|
|
292
|
+
includeSupplementalThemes,
|
|
293
|
+
supplementalThemeNames,
|
|
153
294
|
applyTheme,
|
|
154
295
|
setTheme,
|
|
155
296
|
clearTheme,
|
|
156
297
|
discoverThemes,
|
|
157
298
|
configure,
|
|
158
299
|
initCustomTheme,
|
|
300
|
+
isSupplementalTheme,
|
|
301
|
+
isSupplementalThemeName,
|
|
159
302
|
}
|
|
160
303
|
}
|
|
161
304
|
|
|
@@ -35,11 +35,7 @@ export const examplePeople = [
|
|
|
35
35
|
{ name: 'George', age: 29, city: 'Miami' },
|
|
36
36
|
]
|
|
37
37
|
|
|
38
|
-
/**
|
|
38
|
+
/** Example table rows — the full unique people list. */
|
|
39
39
|
export function getExampleTableRows() {
|
|
40
|
-
return [
|
|
41
|
-
...examplePeople,
|
|
42
|
-
...examplePeople.slice(7),
|
|
43
|
-
...examplePeople.slice(7, 22),
|
|
44
|
-
]
|
|
40
|
+
return [...examplePeople]
|
|
45
41
|
}
|