@fy-/fws-vue-core 3.0.4 → 3.0.6

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 (121) hide show
  1. package/package.json +6 -8
  2. package/src/components/fws/CmsArticleBoxed.vue +247 -0
  3. package/src/components/fws/CmsArticleSingle.vue +201 -0
  4. package/src/components/fws/DataTable.vue +659 -0
  5. package/src/components/fws/FilterData.vue +423 -0
  6. package/src/components/fws/UserData.vue +220 -0
  7. package/src/components/fws/UserFlow.vue +955 -0
  8. package/src/components/fws/UserOAuth2.vue +521 -0
  9. package/src/components/fws/UserProfile.vue +615 -0
  10. package/src/components/fws/UserProfileStrict.vue +233 -0
  11. package/src/components/ssr/ClientOnly.ts +10 -0
  12. package/src/components/ui/DefaultBreadcrumb.vue +99 -0
  13. package/src/components/ui/DefaultConfirm.vue +178 -0
  14. package/src/components/ui/DefaultConfirmWithInput.vue +217 -0
  15. package/src/components/ui/DefaultDropdown.vue +104 -0
  16. package/src/components/ui/DefaultDropdownLink.vue +94 -0
  17. package/src/components/ui/DefaultGallery.vue +1056 -0
  18. package/src/components/ui/DefaultInput.vue +768 -0
  19. package/src/components/ui/DefaultLoader.vue +125 -0
  20. package/src/components/ui/DefaultModal.vue +350 -0
  21. package/src/components/ui/DefaultNotif.vue +332 -0
  22. package/src/components/ui/DefaultPaging.vue +395 -0
  23. package/src/components/ui/DefaultSidebar.vue +267 -0
  24. package/src/components/ui/DefaultTagInput.vue +415 -0
  25. package/src/components/ui/transitions/CollapseTransition.vue +19 -0
  26. package/src/components/ui/transitions/ExpandTransition.vue +19 -0
  27. package/src/components/ui/transitions/FadeTransition.vue +17 -0
  28. package/src/components/ui/transitions/ScaleTransition.vue +21 -0
  29. package/src/components/ui/transitions/SlideTransition.vue +32 -0
  30. package/src/composables/event-bus.ts +15 -0
  31. package/src/composables/rest.ts +165 -0
  32. package/src/composables/seo.ts +142 -0
  33. package/src/composables/ssr.ts +103 -0
  34. package/src/composables/templating.ts +133 -0
  35. package/src/composables/translations.ts +45 -0
  36. package/src/env.d.ts +10 -0
  37. package/{dist/src/index.d.ts → src/index.ts} +71 -45
  38. package/src/plugin.ts +42 -0
  39. package/src/safelist.html +11 -0
  40. package/src/stores/serverRouter.ts +62 -0
  41. package/src/stores/user.ts +118 -0
  42. package/src/types.ts +58 -0
  43. package/dist/index.css +0 -2
  44. package/dist/index.js +0 -5767
  45. package/dist/src/components/fws/CmsArticleBoxed.vue.d.ts +0 -32
  46. package/dist/src/components/fws/CmsArticleBoxed.vue.d.ts.map +0 -1
  47. package/dist/src/components/fws/CmsArticleSingle.vue.d.ts +0 -29
  48. package/dist/src/components/fws/CmsArticleSingle.vue.d.ts.map +0 -1
  49. package/dist/src/components/fws/DataTable.vue.d.ts +0 -52
  50. package/dist/src/components/fws/DataTable.vue.d.ts.map +0 -1
  51. package/dist/src/components/fws/FilterData.vue.d.ts +0 -15
  52. package/dist/src/components/fws/FilterData.vue.d.ts.map +0 -1
  53. package/dist/src/components/fws/UserData.vue.d.ts +0 -8
  54. package/dist/src/components/fws/UserData.vue.d.ts.map +0 -1
  55. package/dist/src/components/fws/UserFlow.vue.d.ts +0 -116
  56. package/dist/src/components/fws/UserFlow.vue.d.ts.map +0 -1
  57. package/dist/src/components/fws/UserOAuth2.vue.d.ts +0 -17
  58. package/dist/src/components/fws/UserOAuth2.vue.d.ts.map +0 -1
  59. package/dist/src/components/fws/UserProfile.vue.d.ts +0 -40
  60. package/dist/src/components/fws/UserProfile.vue.d.ts.map +0 -1
  61. package/dist/src/components/fws/UserProfileStrict.vue.d.ts +0 -12
  62. package/dist/src/components/fws/UserProfileStrict.vue.d.ts.map +0 -1
  63. package/dist/src/components/ssr/ClientOnly.d.ts +0 -4
  64. package/dist/src/components/ssr/ClientOnly.d.ts.map +0 -1
  65. package/dist/src/components/ui/DefaultBreadcrumb.vue.d.ts +0 -11
  66. package/dist/src/components/ui/DefaultBreadcrumb.vue.d.ts.map +0 -1
  67. package/dist/src/components/ui/DefaultConfirm.vue.d.ts +0 -81
  68. package/dist/src/components/ui/DefaultConfirm.vue.d.ts.map +0 -1
  69. package/dist/src/components/ui/DefaultConfirmWithInput.vue.d.ts +0 -81
  70. package/dist/src/components/ui/DefaultConfirmWithInput.vue.d.ts.map +0 -1
  71. package/dist/src/components/ui/DefaultDropdown.vue.d.ts +0 -35
  72. package/dist/src/components/ui/DefaultDropdown.vue.d.ts.map +0 -1
  73. package/dist/src/components/ui/DefaultDropdownLink.vue.d.ts +0 -23
  74. package/dist/src/components/ui/DefaultDropdownLink.vue.d.ts.map +0 -1
  75. package/dist/src/components/ui/DefaultGallery.vue.d.ts +0 -114
  76. package/dist/src/components/ui/DefaultGallery.vue.d.ts.map +0 -1
  77. package/dist/src/components/ui/DefaultInput.vue.d.ts +0 -61
  78. package/dist/src/components/ui/DefaultInput.vue.d.ts.map +0 -1
  79. package/dist/src/components/ui/DefaultLoader.vue.d.ts +0 -12
  80. package/dist/src/components/ui/DefaultLoader.vue.d.ts.map +0 -1
  81. package/dist/src/components/ui/DefaultModal.vue.d.ts +0 -36
  82. package/dist/src/components/ui/DefaultModal.vue.d.ts.map +0 -1
  83. package/dist/src/components/ui/DefaultNotif.vue.d.ts +0 -3
  84. package/dist/src/components/ui/DefaultNotif.vue.d.ts.map +0 -1
  85. package/dist/src/components/ui/DefaultPaging.vue.d.ts +0 -13
  86. package/dist/src/components/ui/DefaultPaging.vue.d.ts.map +0 -1
  87. package/dist/src/components/ui/DefaultSidebar.vue.d.ts +0 -29
  88. package/dist/src/components/ui/DefaultSidebar.vue.d.ts.map +0 -1
  89. package/dist/src/components/ui/DefaultTagInput.vue.d.ts +0 -34
  90. package/dist/src/components/ui/DefaultTagInput.vue.d.ts.map +0 -1
  91. package/dist/src/components/ui/transitions/CollapseTransition.vue.d.ts +0 -18
  92. package/dist/src/components/ui/transitions/CollapseTransition.vue.d.ts.map +0 -1
  93. package/dist/src/components/ui/transitions/ExpandTransition.vue.d.ts +0 -18
  94. package/dist/src/components/ui/transitions/ExpandTransition.vue.d.ts.map +0 -1
  95. package/dist/src/components/ui/transitions/FadeTransition.vue.d.ts +0 -18
  96. package/dist/src/components/ui/transitions/FadeTransition.vue.d.ts.map +0 -1
  97. package/dist/src/components/ui/transitions/ScaleTransition.vue.d.ts +0 -18
  98. package/dist/src/components/ui/transitions/ScaleTransition.vue.d.ts.map +0 -1
  99. package/dist/src/components/ui/transitions/SlideTransition.vue.d.ts +0 -21
  100. package/dist/src/components/ui/transitions/SlideTransition.vue.d.ts.map +0 -1
  101. package/dist/src/composables/event-bus.d.ts +0 -8
  102. package/dist/src/composables/event-bus.d.ts.map +0 -1
  103. package/dist/src/composables/rest.d.ts +0 -24
  104. package/dist/src/composables/rest.d.ts.map +0 -1
  105. package/dist/src/composables/seo.d.ts +0 -26
  106. package/dist/src/composables/seo.d.ts.map +0 -1
  107. package/dist/src/composables/ssr.d.ts +0 -24
  108. package/dist/src/composables/ssr.d.ts.map +0 -1
  109. package/dist/src/composables/templating.d.ts +0 -7
  110. package/dist/src/composables/templating.d.ts.map +0 -1
  111. package/dist/src/composables/translations.d.ts +0 -8
  112. package/dist/src/composables/translations.d.ts.map +0 -1
  113. package/dist/src/index.d.ts.map +0 -1
  114. package/dist/src/plugin.d.ts +0 -3
  115. package/dist/src/plugin.d.ts.map +0 -1
  116. package/dist/src/stores/serverRouter.d.ts +0 -34
  117. package/dist/src/stores/serverRouter.d.ts.map +0 -1
  118. package/dist/src/stores/user.d.ts +0 -139
  119. package/dist/src/stores/user.d.ts.map +0 -1
  120. package/dist/src/types.d.ts +0 -48
  121. package/dist/src/types.d.ts.map +0 -1
@@ -0,0 +1,659 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ ArrowDownIcon,
4
+ ArrowDownTrayIcon,
5
+ ArrowsUpDownIcon,
6
+ ArrowUpIcon,
7
+ } from '@heroicons/vue/24/solid'
8
+ import { useStorage } from '@vueuse/core'
9
+ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
10
+ import { useRoute } from 'vue-router'
11
+ import { useEventBus } from '../../composables/event-bus'
12
+ import { useRest } from '../../composables/rest'
13
+ import DefaultInput from '../ui/DefaultInput.vue'
14
+ import DefaultPaging from '../ui/DefaultPaging.vue'
15
+
16
+ interface DefaultStringObject {
17
+ [key: string]: string
18
+ }
19
+ interface DefaultAnyObject {
20
+ [key: string]: any
21
+ }
22
+ interface DefaultBoolObject {
23
+ [key: string]: boolean
24
+ }
25
+ interface SortingField {
26
+ field: string
27
+ direction: string
28
+ }
29
+
30
+ const eventBus = useEventBus()
31
+ const currentPage = ref<number>(1)
32
+ const route = useRoute()
33
+ const data = ref<any[]>([])
34
+ const paging = ref<any>(undefined)
35
+ const isLoading = ref<boolean>(false)
36
+ const perPageOptions = [
37
+ ['10', '10'],
38
+ ['25', '25'],
39
+ ['50', '50'],
40
+ ['100', '100'],
41
+ ]
42
+
43
+ const props = withDefaults(
44
+ defineProps<{
45
+ id: string
46
+ headers: DefaultStringObject
47
+ sortables?: DefaultBoolObject
48
+ showHeaders?: boolean
49
+ exportableColumns?: string[]
50
+ csvFormatColumns?: Record<string, (value: any) => string>
51
+ defaultPerPage?: number
52
+ filtersData: DefaultAnyObject
53
+ apiPath: string
54
+ defaultSort?: SortingField
55
+ restFunction?: Function | null
56
+ }>(),
57
+ {
58
+ showHeaders: true,
59
+ sortables: () => ({}),
60
+ exportableColumns: () => [],
61
+ csvFormatColumns: () => ({}),
62
+ defaultPerPage: 25,
63
+ defaultSort: () => ({ field: 'Created', direction: 'DESC' }),
64
+ restFunction: null,
65
+ },
66
+ )
67
+
68
+ const rest = useRest()
69
+ const restFunction = props.restFunction ?? rest
70
+ const perPage = useStorage<number>(`${props.id}PerPage`, props.defaultPerPage)
71
+ const currentSort = useStorage<SortingField>(
72
+ `${props.id}CurrentSort`,
73
+ props.defaultSort,
74
+ )
75
+
76
+ const hasData = computed(() => data.value && data.value.length > 0)
77
+ const hasExportableColumns = computed(() => props.exportableColumns.length > 0)
78
+ const hasPaging = computed(() => paging.value && paging.value.page_max > 1 && paging.value.page_no)
79
+
80
+ const currentRequest = ref<AbortController | null>(null)
81
+ const requestCounter = ref<number>(0)
82
+
83
+ async function getData(page: number = 1) {
84
+ const thisRequestNumber = requestCounter.value + 1
85
+ requestCounter.value = thisRequestNumber
86
+
87
+ if (currentRequest.value) {
88
+ currentRequest.value.abort()
89
+ currentRequest.value = null
90
+ }
91
+
92
+ currentRequest.value = new AbortController()
93
+ const signal = currentRequest.value.signal
94
+
95
+ isLoading.value = true
96
+ eventBus.emit('main-loading', true)
97
+
98
+ if (route.query.page) page = Number.parseInt(route.query.page.toString())
99
+ const sort: any = {}
100
+ sort[currentSort.value.field] = currentSort.value.direction
101
+ const requestParams = {
102
+ ...props.filtersData,
103
+ sort,
104
+ results_per_page: perPage.value,
105
+ page_no: page,
106
+ }
107
+
108
+ try {
109
+ const localAbortController = currentRequest.value
110
+ const r = await restFunction(props.apiPath, 'GET', requestParams, {
111
+ getBody: true,
112
+ signal,
113
+ })
114
+
115
+ if (thisRequestNumber === requestCounter.value && localAbortController === currentRequest.value) {
116
+ currentPage.value = page
117
+ data.value = []
118
+ paging.value = undefined
119
+
120
+ if (r && r.result === 'success') {
121
+ data.value = r.data
122
+ paging.value = r.paging
123
+ eventBus.emit(`${props.id}NewData`, data.value)
124
+ }
125
+ }
126
+ }
127
+ catch (error) {
128
+ if (!(error instanceof DOMException && error.name === 'AbortError')
129
+ && thisRequestNumber === requestCounter.value) {
130
+ console.error('Error fetching data:', error)
131
+ }
132
+ }
133
+ finally {
134
+ if (thisRequestNumber === requestCounter.value) {
135
+ if (currentRequest.value && currentRequest.value.signal === signal) {
136
+ currentRequest.value = null
137
+ }
138
+ isLoading.value = false
139
+ eventBus.emit('main-loading', false)
140
+ }
141
+ }
142
+ }
143
+
144
+ function sortData(key: string) {
145
+ if (!props.sortables[key]) return
146
+ const newSort: SortingField = {
147
+ field: currentSort.value.field,
148
+ direction: currentSort.value.direction,
149
+ }
150
+ if (key === newSort.field) {
151
+ newSort.direction = newSort.direction === 'desc' ? 'asc' : 'desc'
152
+ }
153
+ else {
154
+ newSort.direction = 'desc'
155
+ newSort.field = key
156
+ }
157
+ currentSort.value = { ...newSort }
158
+ }
159
+
160
+ function exportToCsv() {
161
+ if (!hasData.value || !hasExportableColumns.value) return
162
+
163
+ const header = props.exportableColumns
164
+ .map(column => props.headers[column] ?? column)
165
+ .join(',')
166
+ const rows = data.value
167
+ .map(row =>
168
+ props.exportableColumns
169
+ .map((column) => {
170
+ let cell = row[column]
171
+ if (props.csvFormatColumns[column]) {
172
+ cell = props.csvFormatColumns[column](row)
173
+ }
174
+ return `"${cell}"`
175
+ })
176
+ .join(','),
177
+ )
178
+ .join('\n')
179
+
180
+ const blob = new Blob([`${header}\n${rows}`], { type: 'text/csv;charset=utf-8;' })
181
+ const link = document.createElement('a')
182
+ const url = URL.createObjectURL(blob)
183
+ link.setAttribute('href', url)
184
+ link.setAttribute(
185
+ 'download',
186
+ `${props.id}_${new Date().toISOString().slice(0, 10)}_Page-${currentPage.value}_${perPage.value}-per-page_Order-by-${currentSort.value.field}-${currentSort.value.direction}.csv`,
187
+ )
188
+ link.style.visibility = 'hidden'
189
+ document.body.appendChild(link)
190
+ link.click()
191
+ document.body.removeChild(link)
192
+ }
193
+
194
+ watch(perPage, () => getData())
195
+ watch(currentSort, () => getData())
196
+ watch(() => props.filtersData, () => getData())
197
+ watch(() => props.apiPath, () => getData())
198
+
199
+ await getData()
200
+
201
+ onMounted(() => {
202
+ eventBus.on(`${props.id}PagesGoToPage`, getData)
203
+ eventBus.on(`${props.id}Reload`, getData)
204
+ eventBus.on(`${props.id}Refresh`, getData)
205
+ })
206
+ onUnmounted(() => {
207
+ eventBus.off(`${props.id}PagesGoToPage`, getData)
208
+ eventBus.off(`${props.id}Reload`, getData)
209
+ eventBus.off(`${props.id}Refresh`, getData)
210
+ })
211
+ </script>
212
+
213
+ <template>
214
+ <div class="fws-table">
215
+ <!-- Controls bar -->
216
+ <div v-if="hasExportableColumns || hasPaging" class="fws-table__controls">
217
+ <div v-if="hasExportableColumns && hasData" class="fws-table__export-wrap">
218
+ <button class="fws-table__export-btn" @click="exportToCsv">
219
+ <ArrowDownTrayIcon class="fws-table__icon-sm" aria-hidden="true" />
220
+ <span class="fws-table__export-label">{{ $t("global_table_export") || 'Export' }}</span>
221
+ </button>
222
+ </div>
223
+ <div v-if="hasPaging" class="fws-table__paging-top">
224
+ <DefaultPaging :id="`${props.id}Pages`" :items="paging" />
225
+ <DefaultInput
226
+ id="perPageSelectTop"
227
+ v-model="perPage"
228
+ :options="perPageOptions"
229
+ :show-label="false"
230
+ type="select"
231
+ class="fws-table__per-page"
232
+ />
233
+ </div>
234
+ </div>
235
+
236
+ <!-- Loading — skeleton rows instead of spinner -->
237
+ <div v-if="isLoading" class="fws-table__loading">
238
+ <div v-for="i in 5" :key="i" class="fws-table__skeleton-row">
239
+ <div v-for="j in Object.keys(headers).length" :key="j" class="fws-table__skeleton-cell">
240
+ <div class="fws-table__skeleton-bar" :style="{ width: `${40 + (j * 17) % 50}%` }" />
241
+ </div>
242
+ </div>
243
+ </div>
244
+
245
+ <!-- Table -->
246
+ <div v-else-if="hasData" class="fws-table__scroll">
247
+ <table class="fws-table__table">
248
+ <thead v-if="showHeaders" class="fws-table__thead">
249
+ <tr>
250
+ <th
251
+ v-for="(header, key) in headers"
252
+ :key="key"
253
+ scope="col"
254
+ class="fws-table__th"
255
+ :class="{ 'fws-table__th--sortable': sortables[key] }"
256
+ @click="sortables[key] ? sortData(key.toString()) : undefined"
257
+ >
258
+ <div class="fws-table__th-inner">
259
+ {{ header }}
260
+ <span v-if="sortables[key]" class="fws-table__sort-icon">
261
+ <ArrowsUpDownIcon v-if="currentSort.field !== key" class="fws-table__icon-sm fws-table__sort-neutral" aria-hidden="true" />
262
+ <ArrowUpIcon v-else-if="currentSort.direction === 'asc'" class="fws-table__icon-sm fws-table__sort-active" aria-hidden="true" />
263
+ <ArrowDownIcon v-else class="fws-table__icon-sm fws-table__sort-active" aria-hidden="true" />
264
+ </span>
265
+ </div>
266
+ </th>
267
+ </tr>
268
+ </thead>
269
+ <tbody>
270
+ <tr
271
+ v-for="(row, index) in data"
272
+ :key="index"
273
+ class="fws-table__tr"
274
+ >
275
+ <td
276
+ v-for="(header, key) in headers"
277
+ :key="key"
278
+ class="fws-table__td"
279
+ >
280
+ <slot :name="key" :value="row">
281
+ <template v-if="row[key]">{{ row[key] }}</template>
282
+ <span v-else class="fws-table__empty-cell">{{ $t("global_table_empty_cell") || '—' }}</span>
283
+ </slot>
284
+ </td>
285
+ </tr>
286
+ </tbody>
287
+ </table>
288
+ </div>
289
+
290
+ <!-- Empty state -->
291
+ <div v-else class="fws-table__empty">
292
+ <svg class="fws-table__empty-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
293
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
294
+ </svg>
295
+ <p class="fws-table__empty-title">{{ $t("no_data_found") || 'No data found' }}</p>
296
+ <p class="fws-table__empty-desc">{{ $t("try_another_search") || 'Try adjusting your filters.' }}</p>
297
+ </div>
298
+
299
+ <!-- Pagination bottom -->
300
+ <div v-if="hasPaging" class="fws-table__paging-bottom">
301
+ <DefaultPaging :id="`${props.id}Pages`" :items="paging" />
302
+ <DefaultInput
303
+ id="perPageSelectBottom"
304
+ v-model="perPage"
305
+ :options="perPageOptions"
306
+ :show-label="false"
307
+ type="select"
308
+ class="fws-table__per-page"
309
+ />
310
+ </div>
311
+ </div>
312
+ </template>
313
+
314
+ <style scoped>
315
+ /* Container — Vercel shadow-as-border + Stripe blue-tinted elevation */
316
+ .fws-table {
317
+ border-radius: 10px;
318
+ overflow: hidden;
319
+ background: #fff;
320
+ box-shadow:
321
+ 0 0 0 1px rgba(0, 0, 0, 0.06),
322
+ rgba(50, 50, 93, 0.08) 0 4px 12px,
323
+ rgba(0, 0, 0, 0.04) 0 1px 3px;
324
+ }
325
+
326
+ .dark .fws-table {
327
+ background: var(--fv-neutral-900, #0a0a0a);
328
+ box-shadow:
329
+ 0 0 0 1px rgba(255, 255, 255, 0.06),
330
+ rgba(0, 0, 0, 0.4) 0 4px 12px;
331
+ }
332
+
333
+ /* Controls bar */
334
+ .fws-table__controls {
335
+ display: flex;
336
+ flex-wrap: wrap;
337
+ align-items: center;
338
+ justify-content: space-between;
339
+ gap: 12px;
340
+ padding: 12px 16px;
341
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
342
+ }
343
+
344
+ .dark .fws-table__controls {
345
+ border-bottom-color: rgba(255, 255, 255, 0.06);
346
+ }
347
+
348
+ .fws-table__export-wrap {
349
+ display: flex;
350
+ align-items: center;
351
+ }
352
+
353
+ /* Export button — Stripe ghost pill */
354
+ .fws-table__export-btn {
355
+ display: flex;
356
+ align-items: center;
357
+ gap: 6px;
358
+ padding: 6px 14px;
359
+ border: none;
360
+ border-radius: 9999px;
361
+ font-size: 13px;
362
+ font-weight: 500;
363
+ color: var(--fv-primary-600, #7c3aed);
364
+ background: rgba(124, 58, 237, 0.06);
365
+ cursor: pointer;
366
+ transition: background 0.15s ease, color 0.15s ease;
367
+ }
368
+
369
+ .fws-table__export-btn:hover {
370
+ background: rgba(124, 58, 237, 0.12);
371
+ }
372
+
373
+ .fws-table__export-btn:active {
374
+ transform: scale(0.97);
375
+ }
376
+
377
+ .fws-table__export-btn:focus-visible {
378
+ outline: 2px solid var(--fv-primary-500, #7c3aed);
379
+ outline-offset: 2px;
380
+ }
381
+
382
+ .dark .fws-table__export-btn {
383
+ color: var(--fv-primary-400, #a78bfa);
384
+ background: rgba(167, 139, 250, 0.08);
385
+ }
386
+
387
+ .dark .fws-table__export-btn:hover {
388
+ background: rgba(167, 139, 250, 0.15);
389
+ }
390
+
391
+ .fws-table__export-label {
392
+ display: none;
393
+ }
394
+
395
+ @media (min-width: 640px) {
396
+ .fws-table__export-label {
397
+ display: inline;
398
+ }
399
+ }
400
+
401
+ .fws-table__paging-top,
402
+ .fws-table__paging-bottom {
403
+ display: flex;
404
+ flex-wrap: wrap;
405
+ align-items: center;
406
+ justify-content: space-between;
407
+ gap: 12px;
408
+ }
409
+
410
+ .fws-table__paging-bottom {
411
+ padding: 10px 16px;
412
+ border-top: 1px solid rgba(0, 0, 0, 0.06);
413
+ }
414
+
415
+ .dark .fws-table__paging-bottom {
416
+ border-top-color: rgba(255, 255, 255, 0.06);
417
+ }
418
+
419
+ .fws-table__per-page {
420
+ width: 80px;
421
+ }
422
+
423
+ .fws-table__icon-sm {
424
+ width: 16px;
425
+ height: 16px;
426
+ }
427
+
428
+ /* Skeleton loading — Linear pattern: shimmer bars */
429
+ .fws-table__loading {
430
+ padding: 0;
431
+ }
432
+
433
+ .fws-table__skeleton-row {
434
+ display: flex;
435
+ gap: 0;
436
+ padding: 0;
437
+ border-bottom: 1px solid rgba(0, 0, 0, 0.04);
438
+ }
439
+
440
+ .dark .fws-table__skeleton-row {
441
+ border-bottom-color: rgba(255, 255, 255, 0.04);
442
+ }
443
+
444
+ .fws-table__skeleton-cell {
445
+ flex: 1;
446
+ padding: 14px 20px;
447
+ }
448
+
449
+ .fws-table__skeleton-bar {
450
+ height: 12px;
451
+ border-radius: 4px;
452
+ background: linear-gradient(90deg, rgba(0, 0, 0, 0.04) 25%, rgba(0, 0, 0, 0.08) 50%, rgba(0, 0, 0, 0.04) 75%);
453
+ background-size: 200% 100%;
454
+ animation: fws-shimmer 1.5s ease infinite;
455
+ }
456
+
457
+ .dark .fws-table__skeleton-bar {
458
+ background: linear-gradient(90deg, rgba(255, 255, 255, 0.03) 25%, rgba(255, 255, 255, 0.06) 50%, rgba(255, 255, 255, 0.03) 75%);
459
+ background-size: 200% 100%;
460
+ }
461
+
462
+ @keyframes fws-shimmer {
463
+ 0% { background-position: 200% 0; }
464
+ 100% { background-position: -200% 0; }
465
+ }
466
+
467
+ /* Table scroll */
468
+ .fws-table__scroll {
469
+ overflow-x: auto;
470
+ }
471
+
472
+ /* Table — Stripe tabular numerals */
473
+ .fws-table__table {
474
+ width: 100%;
475
+ font-size: 14px;
476
+ text-align: left;
477
+ border-collapse: collapse;
478
+ font-variant-numeric: tabular-nums;
479
+ font-feature-settings: "tnum";
480
+ }
481
+
482
+ /* Header — Linear style: sticky, minimal, weight distinction */
483
+ .fws-table__thead {
484
+ font-size: 12px;
485
+ letter-spacing: 0.02em;
486
+ color: var(--fv-neutral-500, #737373);
487
+ border-bottom: 1px solid rgba(0, 0, 0, 0.08);
488
+ position: sticky;
489
+ top: 0;
490
+ z-index: 1;
491
+ background: #fff;
492
+ }
493
+
494
+ .dark .fws-table__thead {
495
+ color: var(--fv-neutral-400, #a3a3a3);
496
+ background: var(--fv-neutral-900, #0a0a0a);
497
+ border-bottom-color: rgba(255, 255, 255, 0.08);
498
+ }
499
+
500
+ .fws-table__th {
501
+ padding: 8px 20px;
502
+ white-space: nowrap;
503
+ font-weight: 500;
504
+ text-transform: none;
505
+ }
506
+
507
+ .fws-table__th--sortable {
508
+ cursor: pointer;
509
+ user-select: none;
510
+ transition: color 0.15s ease;
511
+ }
512
+
513
+ .fws-table__th--sortable:hover {
514
+ color: var(--fv-neutral-800, #1e1e1e);
515
+ }
516
+
517
+ .dark .fws-table__th--sortable:hover {
518
+ color: var(--fv-neutral-200, #e5e5e5);
519
+ }
520
+
521
+ .fws-table__th-inner {
522
+ display: flex;
523
+ align-items: center;
524
+ gap: 4px;
525
+ }
526
+
527
+ .fws-table__sort-icon {
528
+ display: inline-flex;
529
+ }
530
+
531
+ .fws-table__sort-neutral {
532
+ color: var(--fv-neutral-400, #a3a3a3);
533
+ opacity: 0.5;
534
+ }
535
+
536
+ .fws-table__th--sortable:hover .fws-table__sort-neutral {
537
+ opacity: 1;
538
+ }
539
+
540
+ .dark .fws-table__sort-neutral {
541
+ color: var(--fv-neutral-500, #737373);
542
+ }
543
+
544
+ .fws-table__sort-active {
545
+ color: var(--fv-primary-600, #7c3aed);
546
+ }
547
+
548
+ .dark .fws-table__sort-active {
549
+ color: var(--fv-primary-400, #a78bfa);
550
+ }
551
+
552
+ /* Rows — Linear: semi-transparent borders, opacity-step hover, entrance animation */
553
+ .fws-table__tr {
554
+ border-bottom: 1px solid rgba(0, 0, 0, 0.04);
555
+ transition: background 0.1s ease;
556
+ animation: fws-row-in 0.15s ease-out;
557
+ }
558
+
559
+ @keyframes fws-row-in {
560
+ from { opacity: 0.6; }
561
+ to { opacity: 1; }
562
+ }
563
+
564
+ .fws-table__tr:last-child {
565
+ border-bottom: none;
566
+ }
567
+
568
+ .fws-table__tr:hover {
569
+ background: rgba(0, 0, 0, 0.02);
570
+ }
571
+
572
+ .dark .fws-table__tr {
573
+ border-bottom-color: rgba(255, 255, 255, 0.04);
574
+ }
575
+
576
+ .dark .fws-table__tr:hover {
577
+ background: rgba(255, 255, 255, 0.03);
578
+ }
579
+
580
+ .fws-table__td {
581
+ padding: 10px 20px;
582
+ vertical-align: middle;
583
+ line-height: 1.5;
584
+ }
585
+
586
+ .fws-table__empty-cell {
587
+ color: var(--fv-neutral-400, #a3a3a3);
588
+ font-style: italic;
589
+ font-size: 13px;
590
+ }
591
+
592
+ .dark .fws-table__empty-cell {
593
+ color: var(--fv-neutral-600, #525252);
594
+ }
595
+
596
+ /* Empty state — Supabase style: muted, clean */
597
+ .fws-table__empty {
598
+ display: flex;
599
+ flex-direction: column;
600
+ align-items: center;
601
+ justify-content: center;
602
+ padding: 56px 16px;
603
+ text-align: center;
604
+ }
605
+
606
+ .fws-table__empty-icon {
607
+ width: 40px;
608
+ height: 40px;
609
+ margin-bottom: 16px;
610
+ color: var(--fv-neutral-300, #d4d4d4);
611
+ opacity: 0.6;
612
+ }
613
+
614
+ .dark .fws-table__empty-icon {
615
+ color: var(--fv-neutral-600, #525252);
616
+ }
617
+
618
+ .fws-table__empty-title {
619
+ font-size: 15px;
620
+ font-weight: 500;
621
+ color: var(--fv-neutral-600, #525252);
622
+ margin: 0 0 4px;
623
+ }
624
+
625
+ .dark .fws-table__empty-title {
626
+ color: var(--fv-neutral-400, #a3a3a3);
627
+ }
628
+
629
+ .fws-table__empty-desc {
630
+ font-size: 13px;
631
+ color: var(--fv-neutral-400, #a3a3a3);
632
+ margin: 0;
633
+ max-width: 260px;
634
+ }
635
+
636
+ .dark .fws-table__empty-desc {
637
+ color: var(--fv-neutral-500, #737373);
638
+ }
639
+
640
+ /* Mobile */
641
+ @media (max-width: 640px) {
642
+ .fws-table__controls {
643
+ padding: 10px 12px;
644
+ }
645
+
646
+ .fws-table__th,
647
+ .fws-table__td {
648
+ padding: 8px 12px;
649
+ }
650
+
651
+ .fws-table__paging-bottom {
652
+ padding: 10px 12px;
653
+ }
654
+
655
+ .fws-table__skeleton-cell {
656
+ padding: 14px 12px;
657
+ }
658
+ }
659
+ </style>