viur-ui 0.0.2 → 0.0.4
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/index.d.ts +6 -1
- package/package.json +4 -2
- package/src/components/DataTable/DataTable.vue +19 -11
- package/src/components/DataTable/useGridNavigation.ts +59 -0
- package/src/request/request.js +206 -111
- package/src/theme/colorStore.js +2 -1
- package/src/public/assets/icons/fontawesome-manifest.json +0 -8
- package/src/public/assets/icons/regular/file.svg +0 -1
- package/src/public/assets/icons/solid/arrows-left-right.svg +0 -1
- package/src/public/assets/icons/solid/bug.svg +0 -1
- package/src/public/assets/icons/solid/check.svg +0 -1
- package/src/public/assets/icons/solid/circle-info.svg +0 -1
- package/src/public/assets/icons/solid/compress.svg +0 -1
- package/src/public/assets/icons/solid/download.svg +0 -1
- package/src/public/assets/icons/solid/expand.svg +0 -1
- package/src/public/assets/icons/solid/grip-vertical.svg +0 -1
- package/src/public/assets/icons/solid/plus.svg +0 -1
- package/src/public/assets/icons/solid/question.svg +0 -1
- package/src/public/assets/icons/solid/triangle-exclamation.svg +0 -1
- package/src/public/assets/icons/solid/upload.svg +0 -1
- package/src/public/assets/icons/solid/xmark.svg +0 -1
package/index.d.ts
CHANGED
|
@@ -28,9 +28,13 @@ export interface ListFetchOptions {
|
|
|
28
28
|
group?: string | null
|
|
29
29
|
url?: string | null
|
|
30
30
|
renderer?: string
|
|
31
|
+
/** pin the wire format, e.g. `2`/`"v2"` (default: unversioned endpoint) */
|
|
32
|
+
version?: string | number | null
|
|
31
33
|
immediate?: boolean
|
|
32
34
|
cached?: boolean
|
|
33
35
|
ttl?: number
|
|
36
|
+
/** push failures to the message store (when one exists), default `true` */
|
|
37
|
+
messages?: boolean
|
|
34
38
|
/** Extra headers for the LIST requests (not the structure) — object or
|
|
35
39
|
* function, evaluated per request (e.g. X-VIUR-BONELIST). */
|
|
36
40
|
headers?: Record<string, string> | (() => Record<string, string> | null) | null
|
|
@@ -295,7 +299,8 @@ export declare const BRAND_LIGHTNESS: [string, number][]
|
|
|
295
299
|
export interface UploadFileOptions {
|
|
296
300
|
target?: string
|
|
297
301
|
publicUpload?: boolean
|
|
298
|
-
renderer?: string
|
|
302
|
+
renderer?: string | null
|
|
303
|
+
version?: string | number | null
|
|
299
304
|
}
|
|
300
305
|
|
|
301
306
|
export declare function uploadFile(file: File, options?: UploadFileOptions): Promise<Record<string, any>>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viur-ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/viur-framework/viur-ui"
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"vue-router": "^4.5.0"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
|
-
"icons": "npx wa-icons . ./
|
|
39
|
+
"icons": "npx wa-icons . ./stories/public/webawesome/assets/icons",
|
|
40
40
|
"test": "vitest",
|
|
41
41
|
"test:run": "vitest run",
|
|
42
42
|
"story:dev": "histoire dev",
|
|
@@ -59,6 +59,8 @@
|
|
|
59
59
|
"histoire": "^1.0.0-beta.1",
|
|
60
60
|
"lefthook": "^2.1.6",
|
|
61
61
|
"prettier": "^3.8.3",
|
|
62
|
+
"typescript": "^6.0.3",
|
|
63
|
+
"typescript-eslint": "^8.71.0",
|
|
62
64
|
"vite": "^7.3.0",
|
|
63
65
|
"vitest": "^4.1.4",
|
|
64
66
|
"vue": "^3.5.32",
|
|
@@ -310,12 +310,12 @@
|
|
|
310
310
|
role="row"
|
|
311
311
|
:style="state.gridStyle"
|
|
312
312
|
:data-key="displayRow.key"
|
|
313
|
-
@dblclick="onRowDoubleClick(displayRow, $event)"
|
|
314
313
|
:aria-selected="
|
|
315
314
|
selectable !== false && displayRow.kind === 'data' ? state.selectedSet.has(displayRow.nodeKey) : undefined
|
|
316
315
|
"
|
|
317
316
|
:aria-level="state.isTree ? displayRow.depth + 1 : undefined"
|
|
318
317
|
:aria-expanded="displayRow.expandable ? displayRow.expanded : undefined"
|
|
318
|
+
@dblclick="onRowDoubleClick(displayRow, $event)"
|
|
319
319
|
>
|
|
320
320
|
<div
|
|
321
321
|
v-for="cell in state.cellColumns"
|
|
@@ -1782,9 +1782,11 @@ function toggleExpand(displayRow: DisplayRow<R>): void {
|
|
|
1782
1782
|
if (props.loadChildren && displayRow.kind === "data" && displayRow.row) {
|
|
1783
1783
|
const row = displayRow.row
|
|
1784
1784
|
if (childState(row) === "unknown" && !displayRow.node?.children.length) {
|
|
1785
|
-
void fetchChildren(row)
|
|
1786
|
-
|
|
1787
|
-
|
|
1785
|
+
void fetchChildren(row)
|
|
1786
|
+
.then(() => {
|
|
1787
|
+
if (childState(row) !== "empty") expand(displayRow.nodeKey, row)
|
|
1788
|
+
})
|
|
1789
|
+
.catch((error) => console.warn("[DataTable] Ebene konnte nicht aufgeklappt werden", error))
|
|
1788
1790
|
return
|
|
1789
1791
|
}
|
|
1790
1792
|
}
|
|
@@ -2537,7 +2539,7 @@ defineExpose({
|
|
|
2537
2539
|
const row = findRowByKey(key)
|
|
2538
2540
|
if (!row) return
|
|
2539
2541
|
delete state.loadedChildren[String(key)]
|
|
2540
|
-
await fetchChildren(row, true)
|
|
2542
|
+
return await fetchChildren(row, true)
|
|
2541
2543
|
},
|
|
2542
2544
|
/** A row's lazy-loaded children (undefined = not loaded yet). */
|
|
2543
2545
|
getLoadedChildren: (key: RowKey): R[] | undefined => state.loadedChildren[String(key)] as R[] | undefined,
|
|
@@ -2718,16 +2720,17 @@ watch(
|
|
|
2718
2720
|
z-index: 3;
|
|
2719
2721
|
}
|
|
2720
2722
|
/* The row only provides the tint as a variable and lays out the cells —
|
|
2721
|
-
painting happens in the CELLS.
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2723
|
+
painting happens in the CELLS, which always cover the full width of the grid.
|
|
2724
|
+
The row box itself grows to the full width of its column tracks
|
|
2725
|
+
(`min-inline-size: max-content`). Without that it is only as wide as the
|
|
2726
|
+
visible area while the tracks extend beyond it — and cells pinned to the
|
|
2727
|
+
start, being `position: sticky` inside the row, scroll away with the row
|
|
2728
|
+
once the table is scrolled further than one visible width. */
|
|
2727
2729
|
.vi-dt-row {
|
|
2728
2730
|
--dt-row-bg: var(--wa-color-surface-default);
|
|
2729
2731
|
display: grid;
|
|
2730
2732
|
align-items: stretch;
|
|
2733
|
+
min-inline-size: max-content;
|
|
2731
2734
|
min-block-size: var(--dt-row-height, 2.75rem);
|
|
2732
2735
|
background-color: transparent;
|
|
2733
2736
|
}
|
|
@@ -2793,6 +2796,11 @@ watch(
|
|
|
2793
2796
|
background-color: var(--dt-row-bg);
|
|
2794
2797
|
border-block-end: var(--dt-border-width) solid var(--dt-border-color);
|
|
2795
2798
|
overflow: hidden;
|
|
2799
|
+
/* Every row is its own grid, sized by `max-content` (see .vi-dt-row). If
|
|
2800
|
+
cell content counted towards that size, a row with a long text would
|
|
2801
|
+
size its flex columns differently from the others and the columns would
|
|
2802
|
+
no longer line up. Content still overflows and ellipsizes as before. */
|
|
2803
|
+
contain: inline-size;
|
|
2796
2804
|
}
|
|
2797
2805
|
.vi-dt-cell.is-lead {
|
|
2798
2806
|
padding-inline: var(--wa-space-2xs);
|
|
@@ -35,6 +35,48 @@ export interface GridNavigationOptions {
|
|
|
35
35
|
|
|
36
36
|
const RESIZE_STEP = 24
|
|
37
37
|
|
|
38
|
+
const TOGGLE_INPUT_TYPES = new Set(["checkbox", "radio", "button", "submit", "reset", "image"])
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Classifies the control inside a cell that a keydown came from (shadow DOM
|
|
42
|
+
* included). "text" controls own every navigation key, "activate" controls
|
|
43
|
+
* (buttons, checkboxes, switches) only Enter and Space. The walk stops at the
|
|
44
|
+
* cell, so the cell itself never counts.
|
|
45
|
+
*/
|
|
46
|
+
function cellControl(event: KeyboardEvent): { kind: "text" | "activate"; cell: HTMLElement | undefined } | undefined {
|
|
47
|
+
const path = event.composedPath()
|
|
48
|
+
let kind: "text" | "activate" | undefined
|
|
49
|
+
for (const node of path) {
|
|
50
|
+
if (!(node instanceof HTMLElement)) continue
|
|
51
|
+
if (node.hasAttribute("data-row")) return kind ? { kind, cell: node } : undefined
|
|
52
|
+
if (kind === "text") continue
|
|
53
|
+
const tag = node.tagName
|
|
54
|
+
const role = node.getAttribute("role")
|
|
55
|
+
if (
|
|
56
|
+
tag === "TEXTAREA" ||
|
|
57
|
+
tag === "SELECT" ||
|
|
58
|
+
node.isContentEditable ||
|
|
59
|
+
(tag === "INPUT" && !TOGGLE_INPUT_TYPES.has((node as HTMLInputElement).type)) ||
|
|
60
|
+
role === "textbox" ||
|
|
61
|
+
role === "combobox" ||
|
|
62
|
+
role === "listbox"
|
|
63
|
+
) {
|
|
64
|
+
kind = "text"
|
|
65
|
+
} else if (
|
|
66
|
+
tag === "BUTTON" ||
|
|
67
|
+
tag === "INPUT" ||
|
|
68
|
+
(tag === "A" && node.hasAttribute("href")) ||
|
|
69
|
+
role === "button" ||
|
|
70
|
+
role === "checkbox" ||
|
|
71
|
+
role === "switch"
|
|
72
|
+
) {
|
|
73
|
+
kind ??= "activate"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Not inside a cell (e.g. a toolbar control) - leave it alone too.
|
|
77
|
+
return kind ? { kind, cell: undefined } : undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
38
80
|
export function useGridNavigation(scroller: Ref<HTMLElement | undefined>, options: GridNavigationOptions) {
|
|
39
81
|
const state = reactive({
|
|
40
82
|
/** -1 = header row. */
|
|
@@ -73,6 +115,23 @@ export function useGridNavigation(scroller: Ref<HTMLElement | undefined>, option
|
|
|
73
115
|
}
|
|
74
116
|
|
|
75
117
|
function handleKeydown(event: KeyboardEvent): void {
|
|
118
|
+
// ARIA grid: keys inside a cell's control belong to the control,
|
|
119
|
+
// Escape returns focus to the cell.
|
|
120
|
+
const control = cellControl(event)
|
|
121
|
+
if (control) {
|
|
122
|
+
// An open popup (wa-select) closes itself on Escape at document level.
|
|
123
|
+
// composedPath()[0], not event.target: the target is retargeted to the host.
|
|
124
|
+
const origin = event.composedPath()[0]
|
|
125
|
+
const popupOpen = origin instanceof HTMLElement && origin.getAttribute("aria-expanded") === "true"
|
|
126
|
+
if (event.key === "Escape" && !event.defaultPrevented && !popupOpen && control.cell) {
|
|
127
|
+
event.preventDefault()
|
|
128
|
+
control.cell.focus()
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
if (control.kind === "text") return
|
|
132
|
+
if (event.key === "Enter" || event.key === " " || event.key === "Spacebar") return
|
|
133
|
+
}
|
|
134
|
+
|
|
76
135
|
const rows = options.rowCount()
|
|
77
136
|
const cols = options.colCount()
|
|
78
137
|
const inHeader = state.row === -1
|
package/src/request/request.js
CHANGED
|
@@ -62,31 +62,43 @@ let _skeyPending = null
|
|
|
62
62
|
|
|
63
63
|
async function _refillSkeys(renderer) {
|
|
64
64
|
const base = import.meta.env.VITE_API_URL ?? window.location.origin
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
try {
|
|
66
|
+
let response = await fetch(`${base}/${renderer}/skey?amount=${SKEY_BATCH_SIZE}`, { credentials: "include" })
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
response = await fetch(`${base}/${renderer}/skey`, { credentials: "include" })
|
|
69
|
+
if (!response.ok) throw new Error("Failed to fetch skey token")
|
|
70
|
+
}
|
|
71
|
+
let data = await response.json()
|
|
72
|
+
if (!Array.isArray(data)) data = [data]
|
|
73
|
+
_skeyCache.push(...data)
|
|
74
|
+
} finally {
|
|
75
|
+
// also on failure: otherwise every later caller awaits the same rejected promise
|
|
76
|
+
_skeyPending = null
|
|
69
77
|
}
|
|
70
|
-
let data = await response.json()
|
|
71
|
-
if (!Array.isArray(data)) data = [data]
|
|
72
|
-
_skeyCache.push(...data)
|
|
73
|
-
_skeyPending = null
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
async function fetchSkeyToken(renderer) {
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
|
|
81
|
+
try {
|
|
82
|
+
if (_skeyCache.length === 0) {
|
|
83
|
+
if (!_skeyPending) _skeyPending = _refillSkeys(renderer)
|
|
84
|
+
await _skeyPending
|
|
85
|
+
}
|
|
86
|
+
return _skeyCache.shift()
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw error
|
|
80
89
|
}
|
|
81
|
-
return _skeyCache.shift()
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
async function executeAndThrow(fetchResult, executeImpl) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
93
|
+
try {
|
|
94
|
+
await executeImpl()
|
|
95
|
+
if (fetchResult.error.value) {
|
|
96
|
+
throw fetchResult.error.value
|
|
97
|
+
}
|
|
98
|
+
return fetchResult
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw error
|
|
88
101
|
}
|
|
89
|
-
return fetchResult
|
|
90
102
|
}
|
|
91
103
|
|
|
92
104
|
function createControllerListFetch({ all = false, secure = false } = {}) {
|
|
@@ -115,6 +127,11 @@ function createControllerListFetch({ all = false, secure = false } = {}) {
|
|
|
115
127
|
const orders = ref([])
|
|
116
128
|
const params = ref({ ...initialParams })
|
|
117
129
|
let _isNext = false
|
|
130
|
+
// Bumped by execute()/reset(). A request remembers the generation it started
|
|
131
|
+
// in; once a newer one exists it must not write anymore. Aborting alone is
|
|
132
|
+
// not enough: after the list response arrived, afterFetch still awaits the
|
|
133
|
+
// structure round trip and would then write into the already cleared state.
|
|
134
|
+
let _generation = 0
|
|
118
135
|
|
|
119
136
|
function _buildUrl() {
|
|
120
137
|
const base = url ?? create_url({ controller, endpoint: group ? `list/${group}` : "list", renderer, version })
|
|
@@ -135,7 +152,7 @@ function createControllerListFetch({ all = false, secure = false } = {}) {
|
|
|
135
152
|
* Additionally, the structure can be a PAIR LIST
|
|
136
153
|
* (`json.bone.structure.keytuples`) — normalizeStructure flattens that.
|
|
137
154
|
*/
|
|
138
|
-
async function _fetchStructure() {
|
|
155
|
+
async function _fetchStructure(generation) {
|
|
139
156
|
const skeltype =
|
|
140
157
|
params.value.skelType === "node"
|
|
141
158
|
? "viewNodeSkel"
|
|
@@ -146,11 +163,15 @@ function createControllerListFetch({ all = false, secure = false } = {}) {
|
|
|
146
163
|
if (params.value.skelType) {
|
|
147
164
|
url += `${url.includes("?") ? "&" : "?"}skelType=${encodeURIComponent(params.value.skelType)}`
|
|
148
165
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
166
|
+
try {
|
|
167
|
+
const resp = await fetch(url, { credentials: "include", headers: { Accept: "application/json" } })
|
|
168
|
+
if (!resp.ok) return
|
|
169
|
+
const body = normalizeEnvelope(await resp.json())
|
|
170
|
+
const raw = body?.["structure"] ?? body?.[skeltype]
|
|
171
|
+
if (raw && generation === _generation) structure.value = normalizeStructure(raw)
|
|
172
|
+
} catch {
|
|
173
|
+
// the structure is optional, the list stays usable without it
|
|
174
|
+
}
|
|
154
175
|
}
|
|
155
176
|
|
|
156
177
|
const listUrl = ref(_buildUrl())
|
|
@@ -166,7 +187,9 @@ function createControllerListFetch({ all = false, secure = false } = {}) {
|
|
|
166
187
|
const extraHeaders = typeof headers === "function" ? headers() : headers
|
|
167
188
|
if (extraHeaders) options.headers = { ...options.headers, ...extraHeaders }
|
|
168
189
|
if (cached && !_isNext) {
|
|
169
|
-
const hit = await getCached(url, ttl)
|
|
190
|
+
const hit = await getCached(url, ttl).catch((error) => {
|
|
191
|
+
throw error
|
|
192
|
+
})
|
|
170
193
|
if (hit) {
|
|
171
194
|
data.value = hit.data ?? hit.skellist ?? hit
|
|
172
195
|
cursor.value = hit.meta?.cursor ?? hit.cursor ?? ""
|
|
@@ -175,17 +198,29 @@ function createControllerListFetch({ all = false, secure = false } = {}) {
|
|
|
175
198
|
return { options, url }
|
|
176
199
|
}
|
|
177
200
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
201
|
+
const generation = _generation
|
|
202
|
+
if (!secure) return { options, url, generation }
|
|
203
|
+
try {
|
|
204
|
+
const skey = await fetchSkeyToken(renderer)
|
|
205
|
+
const separator = url.includes("?") ? "&" : "?"
|
|
206
|
+
return { url: `${url}${separator}skey=${skey}`, options, generation }
|
|
207
|
+
} catch (error) {
|
|
208
|
+
throw error
|
|
209
|
+
}
|
|
182
210
|
},
|
|
183
211
|
async afterFetch(ctx) {
|
|
184
212
|
// Normalize v1/v2 up to the canonical v2 envelope, then read the list
|
|
185
213
|
// payload from `data` and pagination from `meta`.
|
|
214
|
+
const { generation } = ctx.context
|
|
215
|
+
if (generation !== _generation) return ctx
|
|
186
216
|
const responseData = normalizeEnvelope(ctx.data)
|
|
187
217
|
if (!_isNext) {
|
|
188
|
-
|
|
218
|
+
try {
|
|
219
|
+
await _fetchStructure(generation)
|
|
220
|
+
} catch (error) {
|
|
221
|
+
throw error
|
|
222
|
+
}
|
|
223
|
+
if (generation !== _generation) return ctx
|
|
189
224
|
}
|
|
190
225
|
const list = responseData.data ?? []
|
|
191
226
|
// Some installations deliver the structure along with the list
|
|
@@ -199,12 +234,19 @@ function createControllerListFetch({ all = false, secure = false } = {}) {
|
|
|
199
234
|
data.value = _isNext ? data.value.concat(list) : list
|
|
200
235
|
if (cached && !_isNext) {
|
|
201
236
|
const entityKeys = list.map((s) => s.key).filter(Boolean)
|
|
202
|
-
await setCached(ctx.url ?? listUrl.value, responseData, entityKeys)
|
|
237
|
+
await setCached(ctx.url ?? listUrl.value, responseData, entityKeys).catch((error) => {
|
|
238
|
+
throw error
|
|
239
|
+
})
|
|
203
240
|
}
|
|
204
241
|
ctx.data = data.value
|
|
205
242
|
return ctx
|
|
206
243
|
},
|
|
207
244
|
onFetchError(ctx) {
|
|
245
|
+
// A superseded or aborted request is no failure: keep the current
|
|
246
|
+
// error as it is (null for the run that replaced this one).
|
|
247
|
+
if (ctx.context.generation !== _generation || ctx.error?.name === "AbortError") {
|
|
248
|
+
return { ...ctx, error: error.value }
|
|
249
|
+
}
|
|
208
250
|
ctx.error = new HTTPError(ctx.response?.status, ctx.response?.statusText, ctx.error?.message, ctx.response)
|
|
209
251
|
if (messages) {
|
|
210
252
|
notify(
|
|
@@ -216,7 +258,9 @@ function createControllerListFetch({ all = false, secure = false } = {}) {
|
|
|
216
258
|
},
|
|
217
259
|
})
|
|
218
260
|
|
|
219
|
-
|
|
261
|
+
// all=true starts through our own execute() below, which loads every page
|
|
262
|
+
const fetchOptions = { immediate: immediate && !all }
|
|
263
|
+
const { isFetching, isFinished, error, execute: _execute, abort } = useViurFetch(listUrl, fetchOptions).json()
|
|
220
264
|
|
|
221
265
|
/** Everything loaded: last response is in and no cursor left open. From here
|
|
222
266
|
* on, sorting/filtering may run locally on the dataset. */
|
|
@@ -239,17 +283,16 @@ function createControllerListFetch({ all = false, secure = false } = {}) {
|
|
|
239
283
|
reset,
|
|
240
284
|
}
|
|
241
285
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
listUrl.value = _buildUrl()
|
|
248
|
-
await executeAndThrow(fetchResult, _execute)
|
|
249
|
-
}
|
|
250
|
-
})
|
|
286
|
+
if (all && immediate) {
|
|
287
|
+
execute().catch(() => {
|
|
288
|
+
// already in `error`; rethrowing here would only be an unhandled rejection
|
|
289
|
+
})
|
|
290
|
+
}
|
|
251
291
|
|
|
252
|
-
|
|
292
|
+
/** First page; with all=true it only resolves once every page is in. */
|
|
293
|
+
async function execute() {
|
|
294
|
+
_generation++
|
|
295
|
+
const generation = _generation
|
|
253
296
|
_isNext = false
|
|
254
297
|
abort()
|
|
255
298
|
data.value = []
|
|
@@ -259,40 +302,61 @@ function createControllerListFetch({ all = false, secure = false } = {}) {
|
|
|
259
302
|
const { cursor: _, ...rest } = params.value
|
|
260
303
|
params.value = rest
|
|
261
304
|
listUrl.value = _buildUrl()
|
|
262
|
-
|
|
305
|
+
try {
|
|
306
|
+
const result = await executeAndThrow(fetchResult, _execute)
|
|
307
|
+
// a newer execute()/reset() took over meanwhile — that one loads the rest
|
|
308
|
+
if (all && generation === _generation) await nextAll()
|
|
309
|
+
return result
|
|
310
|
+
} catch (error) {
|
|
311
|
+
throw error
|
|
312
|
+
}
|
|
263
313
|
}
|
|
264
314
|
|
|
265
315
|
/** Load one more page; detects the ViUR end (empty page with the same
|
|
266
316
|
* cursor marker) and then clears the cursor. Returns true on progress. */
|
|
267
317
|
async function _advance() {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
cursor.value
|
|
277
|
-
|
|
318
|
+
try {
|
|
319
|
+
if (!cursor.value) return false
|
|
320
|
+
const before = data.value.length
|
|
321
|
+
const cursorBefore = cursor.value
|
|
322
|
+
params.value = { ...params.value, cursor: cursor.value }
|
|
323
|
+
_isNext = true
|
|
324
|
+
listUrl.value = _buildUrl()
|
|
325
|
+
await executeAndThrow(fetchResult, _execute)
|
|
326
|
+
if (data.value.length === before && cursor.value === cursorBefore) {
|
|
327
|
+
cursor.value = ""
|
|
328
|
+
return false
|
|
329
|
+
}
|
|
330
|
+
return true
|
|
331
|
+
} catch (error) {
|
|
332
|
+
throw error
|
|
278
333
|
}
|
|
279
|
-
return true
|
|
280
334
|
}
|
|
281
335
|
|
|
282
336
|
/** Load the next `pages` pages (default: one) — e.g. directly as the
|
|
283
337
|
* handler of the DataTable cursor bar: `@load-more="({ pages }) =>
|
|
284
338
|
* pages === 'all' ? list.nextAll() : list.next(pages)"`. */
|
|
285
339
|
async function next(pages = 1) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
340
|
+
try {
|
|
341
|
+
let remaining = pages
|
|
342
|
+
while (remaining-- > 0) {
|
|
343
|
+
if (!(await _advance())) return
|
|
344
|
+
}
|
|
345
|
+
} catch (error) {
|
|
346
|
+
throw error
|
|
289
347
|
}
|
|
290
348
|
}
|
|
291
349
|
|
|
292
350
|
/** Load all remaining pages ("load everything"). */
|
|
293
351
|
async function nextAll() {
|
|
294
|
-
|
|
295
|
-
|
|
352
|
+
const generation = _generation
|
|
353
|
+
try {
|
|
354
|
+
// stops when execute()/filter()/reset() restarts the list meanwhile
|
|
355
|
+
while (generation === _generation && (await _advance())) {
|
|
356
|
+
/* until the end */
|
|
357
|
+
}
|
|
358
|
+
} catch (error) {
|
|
359
|
+
throw error
|
|
296
360
|
}
|
|
297
361
|
}
|
|
298
362
|
|
|
@@ -302,6 +366,7 @@ function createControllerListFetch({ all = false, secure = false } = {}) {
|
|
|
302
366
|
}
|
|
303
367
|
|
|
304
368
|
function reset() {
|
|
369
|
+
_generation++
|
|
305
370
|
abort()
|
|
306
371
|
data.value = []
|
|
307
372
|
structure.value = {}
|
|
@@ -367,23 +432,35 @@ export function useTreeFetch(
|
|
|
367
432
|
// detects the ViUR end (empty page with the same cursor marker) itself and
|
|
368
433
|
// then clears its cursor — only progress counts here.
|
|
369
434
|
async function _nextOf(list) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
435
|
+
try {
|
|
436
|
+
const before = list.data.value.length
|
|
437
|
+
await list.next()
|
|
438
|
+
return list.data.value.length > before
|
|
439
|
+
} catch (error) {
|
|
440
|
+
throw error
|
|
441
|
+
}
|
|
373
442
|
}
|
|
374
443
|
|
|
375
444
|
async function next() {
|
|
376
|
-
|
|
377
|
-
|
|
445
|
+
try {
|
|
446
|
+
if (nodes.cursor.value && (await _nextOf(nodes))) return
|
|
447
|
+
if (leaves.cursor.value) await _nextOf(leaves)
|
|
448
|
+
} catch (error) {
|
|
449
|
+
throw error
|
|
450
|
+
}
|
|
378
451
|
}
|
|
379
452
|
|
|
380
453
|
/** Load all remaining pages of both lists ("load everything"). */
|
|
381
454
|
async function nextAll() {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
455
|
+
try {
|
|
456
|
+
while (nodes.cursor.value) {
|
|
457
|
+
if (!(await _nextOf(nodes))) break
|
|
458
|
+
}
|
|
459
|
+
while (leaves.cursor.value) {
|
|
460
|
+
if (!(await _nextOf(leaves))) break
|
|
461
|
+
}
|
|
462
|
+
} catch (error) {
|
|
463
|
+
throw error
|
|
387
464
|
}
|
|
388
465
|
}
|
|
389
466
|
|
|
@@ -459,7 +536,12 @@ export function createControllerFetch({
|
|
|
459
536
|
},
|
|
460
537
|
options: {
|
|
461
538
|
async beforeFetch({ options, url, cancel }) {
|
|
462
|
-
|
|
539
|
+
let skeyToken = null
|
|
540
|
+
try {
|
|
541
|
+
skeyToken = secure ? ctxSkey || (await fetchSkeyToken(renderer)) : null
|
|
542
|
+
} catch (error) {
|
|
543
|
+
throw error
|
|
544
|
+
}
|
|
463
545
|
const base = create_url({ controller, endpoint, renderer, version, lang })
|
|
464
546
|
const extraQs = query ? (typeof query === "string" ? query : new URLSearchParams(query).toString()) : ""
|
|
465
547
|
|
|
@@ -473,7 +555,9 @@ export function createControllerFetch({
|
|
|
473
555
|
_cacheKey = qs ? `${base}?${qs}` : base
|
|
474
556
|
|
|
475
557
|
if (cached) {
|
|
476
|
-
const hit = await getCached(_cacheKey, ttl)
|
|
558
|
+
const hit = await getCached(_cacheKey, ttl).catch((error) => {
|
|
559
|
+
throw error
|
|
560
|
+
})
|
|
477
561
|
if (hit) {
|
|
478
562
|
_cachedHit = hit
|
|
479
563
|
cancel()
|
|
@@ -525,18 +609,25 @@ export function createControllerFetch({
|
|
|
525
609
|
}
|
|
526
610
|
if (cached && method.toUpperCase() === "GET" && _cacheKey) {
|
|
527
611
|
const entityKey = ctx.data?.data?.key
|
|
528
|
-
await setCached(_cacheKey, ctx.data, entityKey ? [entityKey] : [])
|
|
612
|
+
await setCached(_cacheKey, ctx.data, entityKey ? [entityKey] : []).catch((error) => {
|
|
613
|
+
throw error
|
|
614
|
+
})
|
|
529
615
|
}
|
|
530
616
|
if (invalidates) {
|
|
531
617
|
const keys = []
|
|
532
618
|
if (data.key) keys.push(data.key)
|
|
533
619
|
if (ctx.data?.data?.key) keys.push(ctx.data.data.key)
|
|
534
|
-
if (keys.length)
|
|
620
|
+
if (keys.length)
|
|
621
|
+
await invalidateByKey(keys).catch((error) => {
|
|
622
|
+
throw error
|
|
623
|
+
})
|
|
535
624
|
}
|
|
536
625
|
if (invalidatesAll) {
|
|
537
626
|
const base = import.meta.env.VITE_API_URL ?? window.location.origin
|
|
538
627
|
const controllerPath = Array.isArray(controller) ? controller.join("/") : controller
|
|
539
|
-
await clearCache(`${base}/${renderer}/${controllerPath}/`)
|
|
628
|
+
await clearCache(`${base}/${renderer}/${controllerPath}/`).catch((error) => {
|
|
629
|
+
throw error
|
|
630
|
+
})
|
|
540
631
|
}
|
|
541
632
|
return ctx
|
|
542
633
|
},
|
|
@@ -586,47 +677,51 @@ export async function uploadFile(
|
|
|
586
677
|
file,
|
|
587
678
|
{ target = undefined, publicUpload = false, renderer = null, version = null } = {}
|
|
588
679
|
) {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
680
|
+
try {
|
|
681
|
+
const _renderer = renderer ?? import.meta.env.VITE_DEFAULT_RENDERER ?? "json"
|
|
682
|
+
const fetchOpts = { credentials: "include", headers: { Accept: "application/json" }, method: "POST" }
|
|
683
|
+
|
|
684
|
+
// Step 1: get upload URL
|
|
685
|
+
const filedata = {
|
|
686
|
+
fileName: file.name,
|
|
687
|
+
mimeType: file.type || "application/octet-stream",
|
|
688
|
+
size: file.size.toString(),
|
|
689
|
+
}
|
|
690
|
+
if (target) filedata.node = target
|
|
691
|
+
if (publicUpload) filedata.public = true
|
|
692
|
+
const fd1 = _toFormData(filedata)
|
|
693
|
+
fd1.append("skey", await fetchSkeyToken(_renderer))
|
|
694
|
+
|
|
695
|
+
const getUrlResp = await fetch(
|
|
696
|
+
create_url({ controller: "file", endpoint: "getUploadURL", renderer: _renderer, version }),
|
|
697
|
+
{
|
|
698
|
+
...fetchOpts,
|
|
699
|
+
body: fd1,
|
|
700
|
+
}
|
|
701
|
+
)
|
|
702
|
+
if (!getUrlResp.ok) throw new Error(`getUploadURL failed: ${getUrlResp.status}`)
|
|
703
|
+
// Envelope-agnostic: v2 carries the payload in `data`, v1 in `values`.
|
|
704
|
+
const getUrlData = normalizeEnvelope(await getUrlResp.json())
|
|
705
|
+
const { uploadUrl, uploadKey } = getUrlData.data ?? getUrlData.values ?? {}
|
|
602
706
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
707
|
+
// Step 2: upload raw bytes to external storage (no-cors, no credentials)
|
|
708
|
+
await fetch(uploadUrl, { method: "POST", body: file, mode: "no-cors" })
|
|
709
|
+
|
|
710
|
+
// Step 3: register file in ViUR
|
|
711
|
+
const fd3 = _toFormData({ key: uploadKey, skelType: "leaf" })
|
|
712
|
+
fd3.append("skey", await fetchSkeyToken(_renderer))
|
|
713
|
+
|
|
714
|
+
const addResp = await fetch(create_url({ controller: "file", endpoint: "add", renderer: _renderer, version }), {
|
|
606
715
|
...fetchOpts,
|
|
607
|
-
body:
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
await fetch(uploadUrl, { method: "POST", body: file, mode: "no-cors" })
|
|
617
|
-
|
|
618
|
-
// Step 3: register file in ViUR
|
|
619
|
-
const fd3 = _toFormData({ key: uploadKey, skelType: "leaf" })
|
|
620
|
-
fd3.append("skey", await fetchSkeyToken(_renderer))
|
|
621
|
-
|
|
622
|
-
const addResp = await fetch(create_url({ controller: "file", endpoint: "add", renderer: _renderer, version }), {
|
|
623
|
-
...fetchOpts,
|
|
624
|
-
body: fd3,
|
|
625
|
-
})
|
|
626
|
-
if (!addResp.ok) throw new Error(`file/add failed: ${addResp.status}`)
|
|
627
|
-
const addData = normalizeEnvelope(await addResp.json())
|
|
628
|
-
if (addData.status !== "success") throw new Error(`Upload failed: ${JSON.stringify(addData)}`)
|
|
629
|
-
return addData.data ?? addData.values
|
|
716
|
+
body: fd3,
|
|
717
|
+
})
|
|
718
|
+
if (!addResp.ok) throw new Error(`file/add failed: ${addResp.status}`)
|
|
719
|
+
const addData = normalizeEnvelope(await addResp.json())
|
|
720
|
+
if (addData.status !== "success") throw new Error(`Upload failed: ${JSON.stringify(addData)}`)
|
|
721
|
+
return addData.data ?? addData.values
|
|
722
|
+
} catch (error) {
|
|
723
|
+
throw error
|
|
724
|
+
}
|
|
630
725
|
}
|
|
631
726
|
|
|
632
727
|
function _absolute(url) {
|
package/src/theme/colorStore.js
CHANGED
|
@@ -16,7 +16,8 @@ import { useConfigStore } from "../request/configStore.js"
|
|
|
16
16
|
// and dark) as it does for any built-in brand color.
|
|
17
17
|
|
|
18
18
|
/** Lightness (HSL, %) per brand step: the old admin's table (50 → 83 %, …,
|
|
19
|
-
* 950 → 3 %), step by step on WebAwesome's steps (95 … 05)
|
|
19
|
+
* 950 → 3 %), step by step on WebAwesome's steps (95 … 05) — except the
|
|
20
|
+
* lightest step, which is brighter (95 % instead of 83 %). */
|
|
20
21
|
export const BRAND_LIGHTNESS = [
|
|
21
22
|
["95", 95],
|
|
22
23
|
["90", 78],
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M176 48L64 48c-8.8 0-16 7.2-16 16l0 384c0 8.8 7.2 16 16 16l256 0c8.8 0 16-7.2 16-16l0-240-88 0c-39.8 0-72-32.2-72-72l0-88zM316.1 160L224 67.9 224 136c0 13.3 10.7 24 24 24l68.1 0zM0 64C0 28.7 28.7 0 64 0L197.5 0c17 0 33.3 6.7 45.3 18.7L365.3 141.3c12 12 18.7 28.3 18.7 45.3L384 448c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64z"/></svg>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M470.6 374.6l96-96c12.5-12.5 12.5-32.8 0-45.3l-96-96c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l41.4 41.4-357.5 0 41.4-41.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-96 96c-6 6-9.4 14.1-9.4 22.6s3.4 16.6 9.4 22.6l96 96c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-41.4-41.4 357.5 0-41.4 41.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0z"/></svg>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M192 96c0-53 43-96 96-96s96 43 96 96l0 3.6c0 15.7-12.7 28.4-28.4 28.4l-135.1 0c-15.7 0-28.4-12.7-28.4-28.4l0-3.6zm345.6 12.8c10.6 14.1 7.7 34.2-6.4 44.8l-97.8 73.3c5.3 8.9 9.3 18.7 11.8 29.1l98.8 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-96 0 0 32c0 2.6-.1 5.3-.2 7.9l83.4 62.5c14.1 10.6 17 30.7 6.4 44.8s-30.7 17-44.8 6.4l-63.1-47.3c-23.2 44.2-66.5 76.2-117.7 83.9L312 280c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 230.2c-51.2-7.7-94.5-39.7-117.7-83.9L83.2 473.6c-14.1 10.6-34.2 7.7-44.8-6.4s-7.7-34.2 6.4-44.8l83.4-62.5c-.1-2.6-.2-5.2-.2-7.9l0-32-96 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l98.8 0c2.5-10.4 6.5-20.2 11.8-29.1L44.8 153.6c-14.1-10.6-17-30.7-6.4-44.8s30.7-17 44.8-6.4L192 184c12.3-5.1 25.8-8 40-8l112 0c14.2 0 27.7 2.8 40 8l108.8-81.6c14.1-10.6 34.2-7.7 44.8 6.4z"/></svg>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M434.8 70.1c14.3 10.4 17.5 30.4 7.1 44.7l-256 352c-5.5 7.6-14 12.3-23.4 13.1s-18.5-2.7-25.1-9.3l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l101.5 101.5 234-321.7c10.4-14.3 30.4-17.5 44.7-7.1z"/></svg>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M256 512a256 256 0 1 0 0-512 256 256 0 1 0 0 512zM224 160a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm-8 64l48 0c13.3 0 24 10.7 24 24l0 88 8 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l24 0 0-64-24 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/></svg>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M160 64c0-17.7-14.3-32-32-32S96 46.3 96 64l0 64-64 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l96 0c17.7 0 32-14.3 32-32l0-96zM32 320c-17.7 0-32 14.3-32 32s14.3 32 32 32l64 0 0 64c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32l-96 0zM352 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 96c0 17.7 14.3 32 32 32l96 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0 0-64zM320 320c-17.7 0-32 14.3-32 32l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-64 64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-96 0z"/></svg>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M256 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 210.7-41.4-41.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0l96-96c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 242.7 256 32zM64 320c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-46.9 0-56.6 56.6c-31.2 31.2-81.9 31.2-113.1 0L110.9 320 64 320zm304 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/></svg>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M32 32C14.3 32 0 46.3 0 64l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-64 64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 32zM64 352c0-17.7-14.3-32-32-32S0 334.3 0 352l0 96c0 17.7 14.3 32 32 32l96 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0 0-64zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32l64 0 0 64c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32l-96 0zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 64-64 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l96 0c17.7 0 32-14.3 32-32l0-96z"/></svg>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M128 40c0-22.1-17.9-40-40-40L40 0C17.9 0 0 17.9 0 40L0 88c0 22.1 17.9 40 40 40l48 0c22.1 0 40-17.9 40-40l0-48zm0 192c0-22.1-17.9-40-40-40l-48 0c-22.1 0-40 17.9-40 40l0 48c0 22.1 17.9 40 40 40l48 0c22.1 0 40-17.9 40-40l0-48zM0 424l0 48c0 22.1 17.9 40 40 40l48 0c22.1 0 40-17.9 40-40l0-48c0-22.1-17.9-40-40-40l-48 0c-22.1 0-40 17.9-40 40zM320 40c0-22.1-17.9-40-40-40L232 0c-22.1 0-40 17.9-40 40l0 48c0 22.1 17.9 40 40 40l48 0c22.1 0 40-17.9 40-40l0-48zM192 232l0 48c0 22.1 17.9 40 40 40l48 0c22.1 0 40-17.9 40-40l0-48c0-22.1-17.9-40-40-40l-48 0c-22.1 0-40 17.9-40 40zM320 424c0-22.1-17.9-40-40-40l-48 0c-22.1 0-40 17.9-40 40l0 48c0 22.1 17.9 40 40 40l48 0c22.1 0 40-17.9 40-40l0-48z"/></svg>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/></svg>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M64 160c0-53 43-96 96-96s96 43 96 96c0 42.7-27.9 78.9-66.5 91.4-28.4 9.2-61.5 35.3-61.5 76.6l0 24c0 17.7 14.3 32 32 32s32-14.3 32-32l0-24c0-1.7 .6-4.1 3.5-7.3 3-3.3 7.9-6.5 13.7-8.4 64.3-20.7 110.8-81 110.8-152.3 0-88.4-71.6-160-160-160S0 71.6 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32zm96 352c22.1 0 40-17.9 40-40s-17.9-40-40-40-40 17.9-40 40 17.9 40 40 40z"/></svg>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M256 0c14.7 0 28.2 8.1 35.2 21l216 400c6.7 12.4 6.4 27.4-.8 39.5S486.1 480 472 480L40 480c-14.1 0-27.2-7.4-34.4-19.5s-7.5-27.1-.8-39.5l216-400c7-12.9 20.5-21 35.2-21zm0 352a32 32 0 1 0 0 64 32 32 0 1 0 0-64zm0-192c-18.2 0-32.7 15.5-31.4 33.7l7.4 104c.9 12.5 11.4 22.3 23.9 22.3 12.6 0 23-9.7 23.9-22.3l7.4-104c1.3-18.2-13.1-33.7-31.4-33.7z"/></svg>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M256 109.3L256 320c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-210.7-41.4 41.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l96-96c12.5-12.5 32.8-12.5 45.3 0l96 96c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L256 109.3zM224 400c44.2 0 80-35.8 80-80l80 0c35.3 0 64 28.7 64 64l0 32c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64l0-32c0-35.3 28.7-64 64-64l80 0c0 44.2 35.8 80 80 80zm144 24a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/></svg>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/></svg>
|