picocrank 1.24.0 → 1.25.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.
Files changed (36) hide show
  1. package/AGENTS.md +2 -0
  2. package/docs/filter-query-v1.md +180 -0
  3. package/logo.svg +7 -7
  4. package/package.json +7 -5
  5. package/public/themes/README.md +19 -2
  6. package/public/themes/index.json +1 -5
  7. package/styles/dark-theme.css +16 -1
  8. package/supplemental-themes/README.md +9 -0
  9. package/supplemental-themes/catppuccin-latte-frappe/theme.css +276 -0
  10. package/supplemental-themes/dracula-alucard/theme.css +258 -0
  11. package/supplemental-themes/gruvbox-dark-light/theme.css +264 -0
  12. package/supplemental-themes/index.json +17 -0
  13. package/{public/themes → supplemental-themes}/waffles/theme.css +99 -0
  14. package/vite.config.js +91 -0
  15. package/vue/components/Header.vue +3 -3
  16. package/vue/components/Login.vue +1 -3
  17. package/vue/components/Section.vue +45 -14
  18. package/vue/components/Sidebar.vue +2 -1
  19. package/vue/components/SidebarNavLink.vue +6 -0
  20. package/vue/components/Table.vue +1113 -73
  21. package/vue/components/TableColumnFilterPopover.vue +263 -44
  22. package/vue/components/TableColumnOptionsPopover.vue +1116 -0
  23. package/vue/components/ThemeSwitcher.vue +119 -0
  24. package/vue/components/TopBar.vue +2 -2
  25. package/vue/composables/tableColumnPresets.js +296 -0
  26. package/vue/composables/tableFilterQuery.js +202 -0
  27. package/vue/composables/tableFilters.js +167 -3
  28. package/vue/composables/useCustomTheme.js +167 -24
  29. package/vue/data/examplePeople.js +2 -6
  30. package/vue/examples/App.vue +1 -1
  31. package/vue/examples/LoginExample.vue +1 -3
  32. package/vue/examples/TableExample.vue +329 -11
  33. package/vue/examples/TableRemoteExample.vue +112 -4
  34. package/vue/examples/ThemePreview.vue +17 -31
  35. package/public/themes/catppuccin/theme.css +0 -195
  36. package/public/themes/dracula/theme.css +0 -181
@@ -0,0 +1,119 @@
1
+ <template>
2
+ <label class="theme-switcher">
3
+ <span v-if="label" class="theme-switcher-label">{{ label }}</span>
4
+ <select :value="themePreference" @change="onThemeChange">
5
+ <option value="">{{ defaultOptionLabel }}</option>
6
+ <option
7
+ v-for="name in availableThemes"
8
+ :key="name"
9
+ :value="name"
10
+ >
11
+ {{ themeLabels[name] || name }}
12
+ </option>
13
+ </select>
14
+ </label>
15
+ <p v-if="showEmptyHint && availableThemes.length === 0" class="subtle theme-switcher-hint">
16
+ <slot name="empty">
17
+ No drop-in themes found. Add <code>public/themes/&lt;name&gt;/theme.css</code>
18
+ or set <code>include-supplemental-themes</code> to list bundled supplemental themes.
19
+ </slot>
20
+ </p>
21
+ </template>
22
+
23
+ <script setup>
24
+ import { onMounted, watch } from 'vue'
25
+ import { useCustomTheme } from '../composables/useCustomTheme.js'
26
+
27
+ const props = defineProps({
28
+ label: {
29
+ type: String,
30
+ default: 'Active theme',
31
+ },
32
+ defaultOptionLabel: {
33
+ type: String,
34
+ default: 'Default (Femtocrank)',
35
+ },
36
+ includeSupplementalThemes: {
37
+ type: Boolean,
38
+ default: false,
39
+ },
40
+ themesBasePath: {
41
+ type: String,
42
+ default: undefined,
43
+ },
44
+ supplementalThemesBasePath: {
45
+ type: String,
46
+ default: undefined,
47
+ },
48
+ storageKey: {
49
+ type: String,
50
+ default: undefined,
51
+ },
52
+ showEmptyHint: {
53
+ type: Boolean,
54
+ default: false,
55
+ },
56
+ })
57
+
58
+ const emit = defineEmits(['change'])
59
+
60
+ const {
61
+ availableThemes,
62
+ themeLabels,
63
+ themePreference,
64
+ setTheme,
65
+ discoverThemes,
66
+ configure,
67
+ } = useCustomTheme()
68
+
69
+ function syncOptions() {
70
+ configure({
71
+ includeSupplementalThemes: props.includeSupplementalThemes,
72
+ themesBasePath: props.themesBasePath,
73
+ supplementalThemesBasePath: props.supplementalThemesBasePath,
74
+ storageKey: props.storageKey,
75
+ })
76
+ }
77
+
78
+ function onThemeChange(event) {
79
+ const value = event.target.value
80
+ setTheme(value)
81
+ emit('change', value)
82
+ }
83
+
84
+ watch(
85
+ () => [
86
+ props.includeSupplementalThemes,
87
+ props.themesBasePath,
88
+ props.supplementalThemesBasePath,
89
+ props.storageKey,
90
+ ],
91
+ () => {
92
+ syncOptions()
93
+ void discoverThemes()
94
+ },
95
+ )
96
+
97
+ onMounted(() => {
98
+ syncOptions()
99
+ void discoverThemes()
100
+ })
101
+ </script>
102
+
103
+ <style scoped>
104
+ .theme-switcher {
105
+ display: flex;
106
+ flex-wrap: wrap;
107
+ align-items: center;
108
+ gap: 0.75rem;
109
+ margin: 0;
110
+ }
111
+
112
+ .theme-switcher select {
113
+ min-width: 14rem;
114
+ }
115
+
116
+ .theme-switcher-hint {
117
+ margin: 0.75rem 0 0;
118
+ }
119
+ </style>
@@ -97,7 +97,7 @@ function handleLinkClick(callback = null) {
97
97
  padding: 0.5em 0.75em;
98
98
  border-radius: 0.25em;
99
99
  text-decoration: none;
100
- color: inherit;
100
+ color: var(--header-text-color, inherit);
101
101
  transition: background-color 0.15s ease;
102
102
  white-space: nowrap;
103
103
  border: 0;
@@ -124,7 +124,7 @@ function handleLinkClick(callback = null) {
124
124
 
125
125
  html[data-theme="dark"] {
126
126
  .topbar-link {
127
- color: #f8f9fa;
127
+ color: var(--header-text-color, #f8f9fa);
128
128
  }
129
129
 
130
130
  .separator {
@@ -0,0 +1,296 @@
1
+ import {
2
+ normalizeColumnFilterEntries,
3
+ normalizeStoredFilterEntry,
4
+ } from './tableFilters.js'
5
+
6
+ export const PRESET_STORE_VERSION = 1
7
+ export const MAX_PRESETS_PER_TABLE = 20
8
+ export const ALLOWED_PAGE_SIZES = [10, 25, 50, 100]
9
+
10
+ let presetIdCounter = 0
11
+
12
+ export function createPresetId() {
13
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
14
+ return `preset-${crypto.randomUUID()}`
15
+ }
16
+
17
+ presetIdCounter += 1
18
+ return `preset-${presetIdCounter}-${Date.now()}`
19
+ }
20
+
21
+ export function getPresetStorageKey(tableId) {
22
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'ssr'
23
+ return `picocrank-table-layouts:v${PRESET_STORE_VERSION}:${origin}:${tableId}`
24
+ }
25
+
26
+ function createEmptyStore() {
27
+ return {
28
+ version: PRESET_STORE_VERSION,
29
+ defaultPresetId: null,
30
+ presets: [],
31
+ }
32
+ }
33
+
34
+ function normalizeStore(raw) {
35
+ if (!raw || typeof raw !== 'object') {
36
+ return createEmptyStore()
37
+ }
38
+
39
+ const presets = Array.isArray(raw.presets)
40
+ ? raw.presets.filter((preset) => preset?.id && preset?.name && preset?.state)
41
+ : []
42
+
43
+ const defaultPresetId = typeof raw.defaultPresetId === 'string'
44
+ && presets.some((preset) => preset.id === raw.defaultPresetId)
45
+ ? raw.defaultPresetId
46
+ : null
47
+
48
+ return {
49
+ version: PRESET_STORE_VERSION,
50
+ defaultPresetId,
51
+ presets,
52
+ }
53
+ }
54
+
55
+ export function readPresetStore(tableId) {
56
+ if (!tableId || typeof localStorage === 'undefined') {
57
+ return createEmptyStore()
58
+ }
59
+
60
+ try {
61
+ const raw = localStorage.getItem(getPresetStorageKey(tableId))
62
+ if (!raw) {
63
+ return createEmptyStore()
64
+ }
65
+ return normalizeStore(JSON.parse(raw))
66
+ } catch {
67
+ return createEmptyStore()
68
+ }
69
+ }
70
+
71
+ export function writePresetStore(tableId, store) {
72
+ if (!tableId || typeof localStorage === 'undefined') {
73
+ return false
74
+ }
75
+
76
+ localStorage.setItem(getPresetStorageKey(tableId), JSON.stringify(normalizeStore(store)))
77
+ return true
78
+ }
79
+
80
+ export function listPresets(tableId) {
81
+ const store = readPresetStore(tableId)
82
+ return {
83
+ presets: store.presets.map((preset) => ({ ...preset })),
84
+ defaultPresetId: store.defaultPresetId,
85
+ }
86
+ }
87
+
88
+ export function getDefaultPreset(tableId) {
89
+ const store = readPresetStore(tableId)
90
+ if (!store.defaultPresetId) {
91
+ return null
92
+ }
93
+ return store.presets.find((preset) => preset.id === store.defaultPresetId) ?? null
94
+ }
95
+
96
+ function isValidColPriority(priority) {
97
+ return Number.isInteger(priority) && priority >= 1 && priority <= 5
98
+ }
99
+
100
+ function headerMaps(headers = []) {
101
+ const headerByKey = new Map()
102
+ const manageableKeys = []
103
+ const configurableKeys = []
104
+
105
+ for (const header of headers) {
106
+ if (!header?.key || header.hidden) {
107
+ continue
108
+ }
109
+ manageableKeys.push(header.key)
110
+ headerByKey.set(header.key, header)
111
+ if (header.hideable !== false) {
112
+ configurableKeys.push(header.key)
113
+ }
114
+ }
115
+
116
+ return { headerByKey, manageableKeys, configurableKeys }
117
+ }
118
+
119
+ export function sanitizePresetState(state, headers = [], rows = []) {
120
+ if (!state || typeof state !== 'object') {
121
+ return null
122
+ }
123
+
124
+ const { headerByKey, manageableKeys, configurableKeys } = headerMaps(headers)
125
+ const sanitized = {}
126
+
127
+ if (Array.isArray(state.columnOrder)) {
128
+ const fromOrder = state.columnOrder.filter((key) => manageableKeys.includes(key))
129
+ const missing = manageableKeys.filter((key) => !fromOrder.includes(key))
130
+ sanitized.columnOrder = [...fromOrder, ...missing]
131
+ }
132
+
133
+ if (state.columnVisibility && typeof state.columnVisibility === 'object') {
134
+ sanitized.columnVisibility = Object.fromEntries(
135
+ configurableKeys.map((key) => [
136
+ key,
137
+ state.columnVisibility[key] !== false,
138
+ ]),
139
+ )
140
+ }
141
+
142
+ if (state.columnPriorities && typeof state.columnPriorities === 'object') {
143
+ const priorities = {}
144
+ for (const [key, value] of Object.entries(state.columnPriorities)) {
145
+ if (!manageableKeys.includes(key)) {
146
+ continue
147
+ }
148
+ if (value === null || value === '') {
149
+ priorities[key] = null
150
+ continue
151
+ }
152
+ if (isValidColPriority(value)) {
153
+ priorities[key] = value
154
+ }
155
+ }
156
+ sanitized.columnPriorities = priorities
157
+ }
158
+
159
+ if (state.filters && typeof state.filters === 'object') {
160
+ const filters = {}
161
+ for (const [key, columnFilters] of Object.entries(state.filters)) {
162
+ const header = headerByKey.get(key)
163
+ if (!header || header.filterable === false) {
164
+ continue
165
+ }
166
+ const entries = normalizeColumnFilterEntries(columnFilters, header, rows)
167
+ .map((entry) => normalizeStoredFilterEntry(header, entry, rows))
168
+ .filter(Boolean)
169
+ if (entries.length > 0) {
170
+ filters[key] = entries
171
+ }
172
+ }
173
+ sanitized.filters = filters
174
+ }
175
+
176
+ if (state.sortBy) {
177
+ const header = headerByKey.get(state.sortBy)
178
+ if (header?.sortable) {
179
+ sanitized.sortBy = state.sortBy
180
+ sanitized.sortDir = state.sortDir === 'desc' ? 'desc' : 'asc'
181
+ }
182
+ }
183
+
184
+ const rawPageSize = Number(state.pageSize)
185
+ if (Number.isFinite(rawPageSize)) {
186
+ const normalizedPageSize = Math.floor(rawPageSize)
187
+ if (ALLOWED_PAGE_SIZES.includes(normalizedPageSize)) {
188
+ sanitized.pageSize = normalizedPageSize
189
+ }
190
+ }
191
+
192
+ return sanitized
193
+ }
194
+
195
+ export function savePreset(tableId, { name, state, setAsDefault = false }) {
196
+ const trimmedName = String(name ?? '').trim()
197
+ if (!tableId) {
198
+ return { ok: false, error: 'Table identity is required.' }
199
+ }
200
+ if (!trimmedName) {
201
+ return { ok: false, error: 'Preset name is required.' }
202
+ }
203
+ if (!state || typeof state !== 'object') {
204
+ return { ok: false, error: 'Nothing to save.' }
205
+ }
206
+
207
+ const store = readPresetStore(tableId)
208
+ if (store.presets.length >= MAX_PRESETS_PER_TABLE) {
209
+ return { ok: false, error: `Maximum of ${MAX_PRESETS_PER_TABLE} saved layouts reached.` }
210
+ }
211
+
212
+ const preset = {
213
+ id: createPresetId(),
214
+ name: trimmedName,
215
+ savedAt: new Date().toISOString(),
216
+ state: JSON.parse(JSON.stringify(state)),
217
+ }
218
+
219
+ store.presets.push(preset)
220
+ if (setAsDefault) {
221
+ store.defaultPresetId = preset.id
222
+ }
223
+
224
+ writePresetStore(tableId, store)
225
+ return { ok: true, preset, defaultPresetId: store.defaultPresetId }
226
+ }
227
+
228
+ export function deletePreset(tableId, presetId) {
229
+ if (!tableId || !presetId) {
230
+ return { ok: false, error: 'Preset not found.' }
231
+ }
232
+
233
+ const store = readPresetStore(tableId)
234
+ const nextPresets = store.presets.filter((preset) => preset.id !== presetId)
235
+ if (nextPresets.length === store.presets.length) {
236
+ return { ok: false, error: 'Preset not found.' }
237
+ }
238
+
239
+ store.presets = nextPresets
240
+ if (store.defaultPresetId === presetId) {
241
+ store.defaultPresetId = null
242
+ }
243
+
244
+ writePresetStore(tableId, store)
245
+ return { ok: true, defaultPresetId: store.defaultPresetId }
246
+ }
247
+
248
+ export function setDefaultPreset(tableId, presetId) {
249
+ if (!tableId) {
250
+ return { ok: false, error: 'Table identity is required.' }
251
+ }
252
+
253
+ const store = readPresetStore(tableId)
254
+ if (presetId === null) {
255
+ store.defaultPresetId = null
256
+ writePresetStore(tableId, store)
257
+ return { ok: true, defaultPresetId: null }
258
+ }
259
+
260
+ const preset = store.presets.find((item) => item.id === presetId)
261
+ if (!preset) {
262
+ return { ok: false, error: 'Preset not found.' }
263
+ }
264
+
265
+ store.defaultPresetId = presetId
266
+ writePresetStore(tableId, store)
267
+ return { ok: true, defaultPresetId: presetId }
268
+ }
269
+
270
+ export function getPresetById(tableId, presetId) {
271
+ const store = readPresetStore(tableId)
272
+ return store.presets.find((preset) => preset.id === presetId) ?? null
273
+ }
274
+
275
+ export function cloneLayoutState(state) {
276
+ return JSON.parse(JSON.stringify(state ?? {}))
277
+ }
278
+
279
+ export function normalizeLayoutStateForCompare(state, headers = [], rows = []) {
280
+ const sanitized = sanitizePresetState(state, headers, rows) ?? {}
281
+
282
+ return {
283
+ columnOrder: sanitized.columnOrder ?? [],
284
+ columnVisibility: sanitized.columnVisibility ?? {},
285
+ columnPriorities: sanitized.columnPriorities ?? {},
286
+ filters: sanitized.filters ?? {},
287
+ sortBy: sanitized.sortBy ?? null,
288
+ sortDir: sanitized.sortDir === 'desc' ? 'desc' : 'asc',
289
+ pageSize: sanitized.pageSize ?? ALLOWED_PAGE_SIZES[0],
290
+ }
291
+ }
292
+
293
+ export function layoutStatesEqual(left, right, headers = [], rows = []) {
294
+ return JSON.stringify(normalizeLayoutStateForCompare(left, headers, rows))
295
+ === JSON.stringify(normalizeLayoutStateForCompare(right, headers, rows))
296
+ }
@@ -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
+ }