qdadm 0.26.2 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -6
- package/src/components/index.js +3 -0
- package/src/components/layout/AppLayout.vue +150 -4
- package/src/components/pages/LoginPage.vue +267 -0
- package/src/composables/index.js +1 -0
- package/src/composables/useSSE.js +212 -0
- package/src/styles/_breakpoints.scss +65 -0
- package/src/styles/_responsive.scss +244 -0
- package/src/styles/index.scss +19 -0
- package/src/styles/index.css +0 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qdadm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Vue 3 framework for admin dashboards with PrimeVue",
|
|
5
5
|
"author": "quazardous",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"./components": "./src/components/index.js",
|
|
25
25
|
"./module": "./src/module/index.js",
|
|
26
26
|
"./utils": "./src/utils/index.js",
|
|
27
|
-
"./styles": "./src/styles/index.
|
|
27
|
+
"./styles": "./src/styles/index.scss",
|
|
28
|
+
"./styles/breakpoints": "./src/styles/_breakpoints.scss"
|
|
28
29
|
},
|
|
29
30
|
"files": [
|
|
30
31
|
"src",
|
|
@@ -35,11 +36,11 @@
|
|
|
35
36
|
"@quazardous/quarkernel": "^2.1.0"
|
|
36
37
|
},
|
|
37
38
|
"peerDependencies": {
|
|
38
|
-
"vue": "^3.3.0",
|
|
39
|
-
"vue-router": "^4.0.0",
|
|
40
|
-
"primevue": "^4.0.0",
|
|
41
39
|
"pinia": "^2.0.0",
|
|
42
|
-
"
|
|
40
|
+
"primevue": "^4.0.0",
|
|
41
|
+
"vanilla-jsoneditor": "^0.23.0",
|
|
42
|
+
"vue": "^3.3.0",
|
|
43
|
+
"vue-router": "^4.0.0"
|
|
43
44
|
},
|
|
44
45
|
"keywords": [
|
|
45
46
|
"vue",
|
|
@@ -55,6 +56,7 @@
|
|
|
55
56
|
"@vitejs/plugin-vue": "^5.2.1",
|
|
56
57
|
"@vue/test-utils": "^2.4.6",
|
|
57
58
|
"jsdom": "^25.0.1",
|
|
59
|
+
"sass": "^1.97.1",
|
|
58
60
|
"vitest": "^2.1.8"
|
|
59
61
|
}
|
|
60
62
|
}
|
package/src/components/index.js
CHANGED
|
@@ -55,3 +55,6 @@ export { default as EmptyState } from './display/EmptyState.vue'
|
|
|
55
55
|
export { default as IntensityBar } from './display/IntensityBar.vue'
|
|
56
56
|
export { default as BoolCell } from './BoolCell.vue'
|
|
57
57
|
export { default as SeverityTag } from './SeverityTag.vue'
|
|
58
|
+
|
|
59
|
+
// Pages
|
|
60
|
+
export { default as LoginPage } from './pages/LoginPage.vue'
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* </AppLayout>
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { ref, watch, onMounted, computed, inject, provide, useSlots } from 'vue'
|
|
15
|
+
import { ref, watch, onMounted, onUnmounted, computed, inject, provide, useSlots } from 'vue'
|
|
16
16
|
import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
|
|
17
17
|
import { useNavigation } from '../../composables/useNavigation'
|
|
18
18
|
import { useApp } from '../../composables/useApp'
|
|
@@ -42,6 +42,30 @@ const STORAGE_KEY = computed(() => `${app.shortName.toLowerCase()}_nav_collapsed
|
|
|
42
42
|
// Collapsed sections state (section title -> boolean)
|
|
43
43
|
const collapsedSections = ref({})
|
|
44
44
|
|
|
45
|
+
// Mobile sidebar state
|
|
46
|
+
const sidebarOpen = ref(false)
|
|
47
|
+
const MOBILE_BREAKPOINT = 768
|
|
48
|
+
|
|
49
|
+
function toggleSidebar() {
|
|
50
|
+
sidebarOpen.value = !sidebarOpen.value
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function closeSidebar() {
|
|
54
|
+
sidebarOpen.value = false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check if we're on mobile
|
|
58
|
+
function isMobile() {
|
|
59
|
+
return window.innerWidth < MOBILE_BREAKPOINT
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Close sidebar on resize to desktop
|
|
63
|
+
function handleResize() {
|
|
64
|
+
if (!isMobile() && sidebarOpen.value) {
|
|
65
|
+
sidebarOpen.value = false
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
45
69
|
/**
|
|
46
70
|
* Load collapsed state from localStorage
|
|
47
71
|
*/
|
|
@@ -87,13 +111,21 @@ function isSectionExpanded(section) {
|
|
|
87
111
|
return !collapsedSections.value[section.title]
|
|
88
112
|
}
|
|
89
113
|
|
|
90
|
-
// Load state on mount
|
|
114
|
+
// Load state on mount + setup resize listener
|
|
91
115
|
onMounted(() => {
|
|
92
116
|
loadCollapsedState()
|
|
117
|
+
window.addEventListener('resize', handleResize)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
onUnmounted(() => {
|
|
121
|
+
window.removeEventListener('resize', handleResize)
|
|
93
122
|
})
|
|
94
123
|
|
|
95
|
-
// Auto-expand section when navigating to an item in it
|
|
124
|
+
// Auto-expand section when navigating to an item in it + close mobile sidebar
|
|
96
125
|
watch(() => route.path, () => {
|
|
126
|
+
// Close mobile sidebar on navigation
|
|
127
|
+
closeSidebar()
|
|
128
|
+
|
|
97
129
|
for (const section of navSections.value) {
|
|
98
130
|
if (sectionHasActiveItem(section) && collapsedSections.value[section.title]) {
|
|
99
131
|
// Auto-expand but don't save (user can collapse again if they want)
|
|
@@ -149,8 +181,15 @@ const showBreadcrumb = computed(() => {
|
|
|
149
181
|
|
|
150
182
|
<template>
|
|
151
183
|
<div class="app-layout">
|
|
184
|
+
<!-- Mobile overlay -->
|
|
185
|
+
<div
|
|
186
|
+
class="sidebar-overlay"
|
|
187
|
+
:class="{ 'sidebar-overlay--visible': sidebarOpen }"
|
|
188
|
+
@click="closeSidebar"
|
|
189
|
+
></div>
|
|
190
|
+
|
|
152
191
|
<!-- Sidebar -->
|
|
153
|
-
<aside class="sidebar">
|
|
192
|
+
<aside class="sidebar" :class="{ 'sidebar--open': sidebarOpen }">
|
|
154
193
|
<div class="sidebar-header">
|
|
155
194
|
<div class="sidebar-header-top">
|
|
156
195
|
<img v-if="app.logo" :src="app.logo" :alt="app.name" class="sidebar-logo" />
|
|
@@ -216,6 +255,19 @@ const showBreadcrumb = computed(() => {
|
|
|
216
255
|
|
|
217
256
|
<!-- Main content -->
|
|
218
257
|
<main class="main-content">
|
|
258
|
+
<!-- Mobile header bar -->
|
|
259
|
+
<div class="mobile-header">
|
|
260
|
+
<Button
|
|
261
|
+
icon="pi pi-bars"
|
|
262
|
+
severity="secondary"
|
|
263
|
+
text
|
|
264
|
+
class="hamburger-btn"
|
|
265
|
+
@click="toggleSidebar"
|
|
266
|
+
aria-label="Toggle menu"
|
|
267
|
+
/>
|
|
268
|
+
<span class="mobile-header-title">{{ app.name }}</span>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
219
271
|
<!-- Breadcrumb + Navlinks bar -->
|
|
220
272
|
<div v-if="showBreadcrumb" class="layout-nav-bar">
|
|
221
273
|
<Breadcrumb :model="breadcrumbItems" class="layout-breadcrumb">
|
|
@@ -562,4 +614,98 @@ const showBreadcrumb = computed(() => {
|
|
|
562
614
|
.layout-nav-bar :deep(.p-breadcrumb-separator) {
|
|
563
615
|
color: var(--p-surface-400, #94a3b8);
|
|
564
616
|
}
|
|
617
|
+
|
|
618
|
+
/* ============================================
|
|
619
|
+
Mobile / Responsive Styles
|
|
620
|
+
============================================ */
|
|
621
|
+
|
|
622
|
+
.mobile-header {
|
|
623
|
+
display: none;
|
|
624
|
+
align-items: center;
|
|
625
|
+
gap: 0.75rem;
|
|
626
|
+
padding: 0.75rem 1rem;
|
|
627
|
+
background: var(--p-surface-0, white);
|
|
628
|
+
border-bottom: 1px solid var(--p-surface-200, #e2e8f0);
|
|
629
|
+
position: sticky;
|
|
630
|
+
top: 0;
|
|
631
|
+
z-index: 50;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
.mobile-header-title {
|
|
635
|
+
font-weight: 600;
|
|
636
|
+
font-size: 1.125rem;
|
|
637
|
+
color: var(--p-surface-800, #1e293b);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
.hamburger-btn {
|
|
641
|
+
display: none !important;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/* Sidebar overlay for mobile */
|
|
645
|
+
.sidebar-overlay {
|
|
646
|
+
display: none;
|
|
647
|
+
position: fixed;
|
|
648
|
+
inset: 0;
|
|
649
|
+
background: rgba(0, 0, 0, 0.5);
|
|
650
|
+
z-index: 99;
|
|
651
|
+
opacity: 0;
|
|
652
|
+
transition: opacity 0.25s ease;
|
|
653
|
+
pointer-events: none;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/* Tablet breakpoint (< 768px) */
|
|
657
|
+
@media (max-width: 767px) {
|
|
658
|
+
.mobile-header {
|
|
659
|
+
display: flex;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
.hamburger-btn {
|
|
663
|
+
display: flex !important;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.sidebar {
|
|
667
|
+
transform: translateX(-100%);
|
|
668
|
+
transition: transform 0.25s ease;
|
|
669
|
+
box-shadow: none;
|
|
670
|
+
width: min(80vw, 280px);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
.sidebar.sidebar--open {
|
|
674
|
+
transform: translateX(0);
|
|
675
|
+
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.15);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
.sidebar-overlay {
|
|
679
|
+
display: block;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
.sidebar-overlay.sidebar-overlay--visible {
|
|
683
|
+
opacity: 1;
|
|
684
|
+
pointer-events: auto;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
.main-content {
|
|
688
|
+
margin-left: 0;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.page-content {
|
|
692
|
+
padding: 1rem;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
.layout-nav-bar {
|
|
696
|
+
padding: 1rem;
|
|
697
|
+
padding-bottom: 0;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/* Large tablet breakpoint (768px - 1023px) */
|
|
702
|
+
@media (min-width: 768px) and (max-width: 1023px) {
|
|
703
|
+
.sidebar {
|
|
704
|
+
width: 12rem;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.main-content {
|
|
708
|
+
margin-left: 12rem;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
565
711
|
</style>
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* LoginPage - Generic login page component
|
|
4
|
+
*
|
|
5
|
+
* Uses authAdapter from qdadm context for authentication.
|
|
6
|
+
* Customizable via props and slots for app branding.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* <LoginPage />
|
|
10
|
+
* <LoginPage title="My App" icon="pi pi-lock" />
|
|
11
|
+
* <LoginPage :logo="LogoComponent" />
|
|
12
|
+
*
|
|
13
|
+
* With slot:
|
|
14
|
+
* <LoginPage>
|
|
15
|
+
* <template #footer>
|
|
16
|
+
* <p>Demo accounts: admin, user</p>
|
|
17
|
+
* </template>
|
|
18
|
+
* </LoginPage>
|
|
19
|
+
*/
|
|
20
|
+
import { ref, inject, computed } from 'vue'
|
|
21
|
+
import { useRouter } from 'vue-router'
|
|
22
|
+
import { useToast } from 'primevue/usetoast'
|
|
23
|
+
import Card from 'primevue/card'
|
|
24
|
+
import InputText from 'primevue/inputtext'
|
|
25
|
+
import Password from 'primevue/password'
|
|
26
|
+
import Button from 'primevue/button'
|
|
27
|
+
|
|
28
|
+
const props = defineProps({
|
|
29
|
+
/**
|
|
30
|
+
* Override app title (defaults to qdadm app.name config)
|
|
31
|
+
*/
|
|
32
|
+
title: {
|
|
33
|
+
type: String,
|
|
34
|
+
default: null
|
|
35
|
+
},
|
|
36
|
+
/**
|
|
37
|
+
* PrimeIcons icon class (e.g., 'pi pi-lock')
|
|
38
|
+
*/
|
|
39
|
+
icon: {
|
|
40
|
+
type: String,
|
|
41
|
+
default: 'pi pi-shield'
|
|
42
|
+
},
|
|
43
|
+
/**
|
|
44
|
+
* Custom logo component (replaces icon)
|
|
45
|
+
*/
|
|
46
|
+
logo: {
|
|
47
|
+
type: Object,
|
|
48
|
+
default: null
|
|
49
|
+
},
|
|
50
|
+
/**
|
|
51
|
+
* Username field label
|
|
52
|
+
*/
|
|
53
|
+
usernameLabel: {
|
|
54
|
+
type: String,
|
|
55
|
+
default: 'Username'
|
|
56
|
+
},
|
|
57
|
+
/**
|
|
58
|
+
* Password field label
|
|
59
|
+
*/
|
|
60
|
+
passwordLabel: {
|
|
61
|
+
type: String,
|
|
62
|
+
default: 'Password'
|
|
63
|
+
},
|
|
64
|
+
/**
|
|
65
|
+
* Submit button label
|
|
66
|
+
*/
|
|
67
|
+
submitLabel: {
|
|
68
|
+
type: String,
|
|
69
|
+
default: 'Sign In'
|
|
70
|
+
},
|
|
71
|
+
/**
|
|
72
|
+
* Route to redirect after successful login
|
|
73
|
+
*/
|
|
74
|
+
redirectTo: {
|
|
75
|
+
type: String,
|
|
76
|
+
default: '/'
|
|
77
|
+
},
|
|
78
|
+
/**
|
|
79
|
+
* Default username value
|
|
80
|
+
*/
|
|
81
|
+
defaultUsername: {
|
|
82
|
+
type: String,
|
|
83
|
+
default: ''
|
|
84
|
+
},
|
|
85
|
+
/**
|
|
86
|
+
* Default password value
|
|
87
|
+
*/
|
|
88
|
+
defaultPassword: {
|
|
89
|
+
type: String,
|
|
90
|
+
default: ''
|
|
91
|
+
},
|
|
92
|
+
/**
|
|
93
|
+
* Emit business signal on login (requires orchestrator)
|
|
94
|
+
*/
|
|
95
|
+
emitSignal: {
|
|
96
|
+
type: Boolean,
|
|
97
|
+
default: false
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const emit = defineEmits(['login', 'error'])
|
|
102
|
+
|
|
103
|
+
const router = useRouter()
|
|
104
|
+
const toast = useToast()
|
|
105
|
+
const authAdapter = inject('authAdapter', null)
|
|
106
|
+
const orchestrator = inject('qdadmOrchestrator', null)
|
|
107
|
+
const appConfig = inject('qdadmApp', {})
|
|
108
|
+
|
|
109
|
+
const username = ref(props.defaultUsername)
|
|
110
|
+
const password = ref(props.defaultPassword)
|
|
111
|
+
const loading = ref(false)
|
|
112
|
+
|
|
113
|
+
const displayTitle = computed(() => props.title || appConfig.name || 'Admin')
|
|
114
|
+
|
|
115
|
+
async function handleLogin() {
|
|
116
|
+
if (!authAdapter?.login) {
|
|
117
|
+
toast.add({
|
|
118
|
+
severity: 'error',
|
|
119
|
+
summary: 'Configuration Error',
|
|
120
|
+
detail: 'No auth adapter configured',
|
|
121
|
+
life: 5000
|
|
122
|
+
})
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
loading.value = true
|
|
127
|
+
try {
|
|
128
|
+
const result = await authAdapter.login({
|
|
129
|
+
username: username.value,
|
|
130
|
+
password: password.value
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// Emit business signal if enabled
|
|
134
|
+
if (props.emitSignal && orchestrator?.signals) {
|
|
135
|
+
orchestrator.signals.emit('auth:login', { user: result.user })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Emit component event
|
|
139
|
+
emit('login', result)
|
|
140
|
+
|
|
141
|
+
router.push(props.redirectTo)
|
|
142
|
+
} catch (error) {
|
|
143
|
+
toast.add({
|
|
144
|
+
severity: 'error',
|
|
145
|
+
summary: 'Login Failed',
|
|
146
|
+
detail: error.message || 'Invalid credentials',
|
|
147
|
+
life: 3000
|
|
148
|
+
})
|
|
149
|
+
emit('error', error)
|
|
150
|
+
} finally {
|
|
151
|
+
loading.value = false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
</script>
|
|
155
|
+
|
|
156
|
+
<template>
|
|
157
|
+
<div class="qdadm-login-page">
|
|
158
|
+
<Card class="qdadm-login-card">
|
|
159
|
+
<template #title>
|
|
160
|
+
<div class="qdadm-login-header">
|
|
161
|
+
<slot name="logo">
|
|
162
|
+
<component v-if="logo" :is="logo" class="qdadm-login-logo" />
|
|
163
|
+
<i v-else :class="icon" class="qdadm-login-icon"></i>
|
|
164
|
+
</slot>
|
|
165
|
+
<h1>{{ displayTitle }}</h1>
|
|
166
|
+
</div>
|
|
167
|
+
</template>
|
|
168
|
+
<template #content>
|
|
169
|
+
<form @submit.prevent="handleLogin" class="qdadm-login-form">
|
|
170
|
+
<div class="qdadm-login-field">
|
|
171
|
+
<label for="qdadm-username">{{ usernameLabel }}</label>
|
|
172
|
+
<InputText
|
|
173
|
+
v-model="username"
|
|
174
|
+
id="qdadm-username"
|
|
175
|
+
class="w-full"
|
|
176
|
+
autocomplete="username"
|
|
177
|
+
:disabled="loading"
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
<div class="qdadm-login-field">
|
|
181
|
+
<label for="qdadm-password">{{ passwordLabel }}</label>
|
|
182
|
+
<Password
|
|
183
|
+
v-model="password"
|
|
184
|
+
id="qdadm-password"
|
|
185
|
+
class="w-full"
|
|
186
|
+
:feedback="false"
|
|
187
|
+
toggleMask
|
|
188
|
+
:disabled="loading"
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
<Button
|
|
192
|
+
type="submit"
|
|
193
|
+
:label="submitLabel"
|
|
194
|
+
icon="pi pi-sign-in"
|
|
195
|
+
class="w-full"
|
|
196
|
+
:loading="loading"
|
|
197
|
+
/>
|
|
198
|
+
<slot name="footer"></slot>
|
|
199
|
+
</form>
|
|
200
|
+
</template>
|
|
201
|
+
</Card>
|
|
202
|
+
</div>
|
|
203
|
+
</template>
|
|
204
|
+
|
|
205
|
+
<style scoped>
|
|
206
|
+
.qdadm-login-page {
|
|
207
|
+
min-height: 100vh;
|
|
208
|
+
display: flex;
|
|
209
|
+
align-items: center;
|
|
210
|
+
justify-content: center;
|
|
211
|
+
background: var(--p-surface-100);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.qdadm-login-card {
|
|
215
|
+
width: 100%;
|
|
216
|
+
max-width: 400px;
|
|
217
|
+
margin: 1rem;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.qdadm-login-header {
|
|
221
|
+
display: flex;
|
|
222
|
+
align-items: center;
|
|
223
|
+
gap: 0.75rem;
|
|
224
|
+
justify-content: center;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.qdadm-login-header h1 {
|
|
228
|
+
margin: 0;
|
|
229
|
+
font-size: 1.5rem;
|
|
230
|
+
color: var(--p-text-color);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.qdadm-login-icon {
|
|
234
|
+
font-size: 2rem;
|
|
235
|
+
color: var(--p-primary-500);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.qdadm-login-logo {
|
|
239
|
+
height: 2rem;
|
|
240
|
+
width: auto;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.qdadm-login-form {
|
|
244
|
+
display: flex;
|
|
245
|
+
flex-direction: column;
|
|
246
|
+
gap: 1rem;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.qdadm-login-field {
|
|
250
|
+
display: flex;
|
|
251
|
+
flex-direction: column;
|
|
252
|
+
gap: 0.5rem;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.qdadm-login-field label {
|
|
256
|
+
font-weight: 500;
|
|
257
|
+
color: var(--p-text-color);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* Responsive */
|
|
261
|
+
@media (max-width: 480px) {
|
|
262
|
+
.qdadm-login-card {
|
|
263
|
+
max-width: 100%;
|
|
264
|
+
margin: 0.5rem;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
</style>
|
package/src/composables/index.js
CHANGED
|
@@ -20,3 +20,4 @@ export { useSignals } from './useSignals'
|
|
|
20
20
|
export { useZoneRegistry } from './useZoneRegistry'
|
|
21
21
|
export { useHooks } from './useHooks'
|
|
22
22
|
export { useLayoutResolver, createLayoutComponents, layoutMeta, LAYOUT_TYPES } from './useLayoutResolver'
|
|
23
|
+
export { useSSE } from './useSSE'
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSSE - Server-Sent Events composable
|
|
3
|
+
*
|
|
4
|
+
* Manages EventSource connection with automatic reconnection and cleanup.
|
|
5
|
+
* Uses authAdapter.getToken() for authentication if available.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const { connected, error, reconnect, close } = useSSE('/api/events', {
|
|
9
|
+
* eventHandlers: {
|
|
10
|
+
* 'bot:status': (data) => console.log('Bot status:', data),
|
|
11
|
+
* 'task:complete': (data) => handleTaskComplete(data)
|
|
12
|
+
* }
|
|
13
|
+
* })
|
|
14
|
+
*
|
|
15
|
+
* Options:
|
|
16
|
+
* - eventHandlers: Object mapping event names to handler functions
|
|
17
|
+
* - reconnectDelay: Delay in ms before reconnecting (default: 5000)
|
|
18
|
+
* - autoConnect: Start connection immediately (default: true)
|
|
19
|
+
* - withCredentials: Include credentials in request (default: false)
|
|
20
|
+
* - tokenParam: Query param name for token (default: 'token')
|
|
21
|
+
* - getToken: Custom token getter function (default: uses authAdapter)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { ref, inject, onUnmounted, onMounted } from 'vue'
|
|
25
|
+
|
|
26
|
+
export function useSSE(url, options = {}) {
|
|
27
|
+
const {
|
|
28
|
+
eventHandlers = {},
|
|
29
|
+
reconnectDelay = 5000,
|
|
30
|
+
autoConnect = true,
|
|
31
|
+
withCredentials = false,
|
|
32
|
+
tokenParam = 'token',
|
|
33
|
+
getToken: customGetToken = null
|
|
34
|
+
} = options
|
|
35
|
+
|
|
36
|
+
const authAdapter = inject('authAdapter', null)
|
|
37
|
+
|
|
38
|
+
const connected = ref(false)
|
|
39
|
+
const error = ref(null)
|
|
40
|
+
const reconnecting = ref(false)
|
|
41
|
+
|
|
42
|
+
let eventSource = null
|
|
43
|
+
let reconnectTimer = null
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get authentication token
|
|
47
|
+
*/
|
|
48
|
+
const getToken = () => {
|
|
49
|
+
// Custom getter takes precedence
|
|
50
|
+
if (customGetToken) {
|
|
51
|
+
return customGetToken()
|
|
52
|
+
}
|
|
53
|
+
// Try authAdapter
|
|
54
|
+
if (authAdapter?.getToken) {
|
|
55
|
+
return authAdapter.getToken()
|
|
56
|
+
}
|
|
57
|
+
// Fallback to localStorage
|
|
58
|
+
return localStorage.getItem('auth_token')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build SSE URL with token
|
|
63
|
+
*/
|
|
64
|
+
const buildUrl = () => {
|
|
65
|
+
const token = getToken()
|
|
66
|
+
const sseUrl = new URL(url, window.location.origin)
|
|
67
|
+
|
|
68
|
+
if (token && tokenParam) {
|
|
69
|
+
sseUrl.searchParams.set(tokenParam, token)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return sseUrl.toString()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Connect to SSE endpoint
|
|
77
|
+
*/
|
|
78
|
+
const connect = () => {
|
|
79
|
+
// Clean up existing connection
|
|
80
|
+
if (eventSource) {
|
|
81
|
+
eventSource.close()
|
|
82
|
+
eventSource = null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Clear any pending reconnect
|
|
86
|
+
if (reconnectTimer) {
|
|
87
|
+
clearTimeout(reconnectTimer)
|
|
88
|
+
reconnectTimer = null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const sseUrl = buildUrl()
|
|
93
|
+
|
|
94
|
+
eventSource = new EventSource(sseUrl, {
|
|
95
|
+
withCredentials
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
eventSource.onopen = () => {
|
|
99
|
+
connected.value = true
|
|
100
|
+
error.value = null
|
|
101
|
+
reconnecting.value = false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
eventSource.onerror = (err) => {
|
|
105
|
+
connected.value = false
|
|
106
|
+
error.value = 'Connection error'
|
|
107
|
+
|
|
108
|
+
// Close broken connection
|
|
109
|
+
if (eventSource) {
|
|
110
|
+
eventSource.close()
|
|
111
|
+
eventSource = null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Schedule reconnect
|
|
115
|
+
if (reconnectDelay > 0) {
|
|
116
|
+
reconnecting.value = true
|
|
117
|
+
reconnectTimer = setTimeout(() => {
|
|
118
|
+
if (!connected.value) {
|
|
119
|
+
connect()
|
|
120
|
+
}
|
|
121
|
+
}, reconnectDelay)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Register custom event handlers
|
|
126
|
+
for (const [eventName, handler] of Object.entries(eventHandlers)) {
|
|
127
|
+
if (eventName === 'message') continue // Handle below
|
|
128
|
+
|
|
129
|
+
eventSource.addEventListener(eventName, (event) => {
|
|
130
|
+
try {
|
|
131
|
+
const data = JSON.parse(event.data)
|
|
132
|
+
handler(data, event)
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error(`[useSSE] Error parsing event "${eventName}":`, err)
|
|
135
|
+
// Call handler with raw data on parse error
|
|
136
|
+
handler(event.data, event)
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Handle generic message events
|
|
142
|
+
eventSource.onmessage = (event) => {
|
|
143
|
+
if (!eventHandlers.message) return
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const data = JSON.parse(event.data)
|
|
147
|
+
eventHandlers.message(data, event)
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error('[useSSE] Error parsing message:', err)
|
|
150
|
+
eventHandlers.message(event.data, event)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
} catch (err) {
|
|
155
|
+
error.value = err.message
|
|
156
|
+
connected.value = false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Close connection
|
|
162
|
+
*/
|
|
163
|
+
const close = () => {
|
|
164
|
+
if (reconnectTimer) {
|
|
165
|
+
clearTimeout(reconnectTimer)
|
|
166
|
+
reconnectTimer = null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (eventSource) {
|
|
170
|
+
eventSource.close()
|
|
171
|
+
eventSource = null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
connected.value = false
|
|
175
|
+
reconnecting.value = false
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Reconnect (close and connect)
|
|
180
|
+
*/
|
|
181
|
+
const reconnect = () => {
|
|
182
|
+
close()
|
|
183
|
+
connect()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Auto-connect on mount if enabled
|
|
187
|
+
onMounted(() => {
|
|
188
|
+
if (autoConnect) {
|
|
189
|
+
connect()
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Cleanup on unmount
|
|
194
|
+
onUnmounted(() => {
|
|
195
|
+
close()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
/** Reactive connection state */
|
|
200
|
+
connected,
|
|
201
|
+
/** Reactive error message */
|
|
202
|
+
error,
|
|
203
|
+
/** Reactive reconnecting state */
|
|
204
|
+
reconnecting,
|
|
205
|
+
/** Manually connect */
|
|
206
|
+
connect,
|
|
207
|
+
/** Close connection */
|
|
208
|
+
close,
|
|
209
|
+
/** Reconnect (close + connect) */
|
|
210
|
+
reconnect
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* qdadm - Responsive Breakpoints
|
|
3
|
+
*
|
|
4
|
+
* Strategy: Desktop-first (PC > tablet > mobile)
|
|
5
|
+
*
|
|
6
|
+
* Usage in components:
|
|
7
|
+
* @use 'qdadm/styles/breakpoints' as *;
|
|
8
|
+
* .sidebar { @include tablet { width: 60px; } }
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Breakpoint values
|
|
12
|
+
$bp-desktop-lg: 1400px; // Large desktop
|
|
13
|
+
$bp-desktop: 1200px; // Standard desktop
|
|
14
|
+
$bp-tablet-lg: 1024px; // Large tablet / small laptop
|
|
15
|
+
$bp-tablet: 768px; // Tablet portrait
|
|
16
|
+
$bp-mobile-lg: 576px; // Large mobile
|
|
17
|
+
$bp-mobile: 480px; // Standard mobile
|
|
18
|
+
|
|
19
|
+
// Sidebar behavior threshold
|
|
20
|
+
$bp-sidebar-collapse: $bp-tablet-lg; // Sidebar collapses below this
|
|
21
|
+
$bp-sidebar-hidden: $bp-tablet; // Sidebar becomes drawer below this
|
|
22
|
+
|
|
23
|
+
// Mixins - Desktop-first (max-width)
|
|
24
|
+
@mixin desktop-lg {
|
|
25
|
+
@media (max-width: #{$bp-desktop-lg - 1px}) { @content; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@mixin desktop {
|
|
29
|
+
@media (max-width: #{$bp-desktop - 1px}) { @content; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@mixin tablet-lg {
|
|
33
|
+
@media (max-width: #{$bp-tablet-lg - 1px}) { @content; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@mixin tablet {
|
|
37
|
+
@media (max-width: #{$bp-tablet - 1px}) { @content; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@mixin mobile-lg {
|
|
41
|
+
@media (max-width: #{$bp-mobile-lg - 1px}) { @content; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@mixin mobile {
|
|
45
|
+
@media (max-width: #{$bp-mobile - 1px}) { @content; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Utility: hide/show at breakpoints
|
|
49
|
+
@mixin hide-below($bp) {
|
|
50
|
+
@media (max-width: #{$bp - 1px}) { display: none !important; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@mixin show-below($bp) {
|
|
54
|
+
display: none !important;
|
|
55
|
+
@media (max-width: #{$bp - 1px}) { display: block !important; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Sidebar-specific mixins
|
|
59
|
+
@mixin sidebar-collapsed {
|
|
60
|
+
@media (max-width: #{$bp-sidebar-collapse - 1px}) { @content; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@mixin sidebar-hidden {
|
|
64
|
+
@media (max-width: #{$bp-sidebar-hidden - 1px}) { @content; }
|
|
65
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* qdadm - Responsive Layout Styles
|
|
3
|
+
*
|
|
4
|
+
* Global responsive rules for layout components.
|
|
5
|
+
* These complement the scoped styles in Vue components.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
@use 'breakpoints' as *;
|
|
9
|
+
|
|
10
|
+
// ============================================
|
|
11
|
+
// CSS Variables for responsive behavior
|
|
12
|
+
// ============================================
|
|
13
|
+
|
|
14
|
+
:root {
|
|
15
|
+
// Sidebar
|
|
16
|
+
--qdadm-sidebar-width: 15rem;
|
|
17
|
+
--qdadm-sidebar-collapsed-width: 4rem;
|
|
18
|
+
--qdadm-sidebar-transition: 0.25s ease;
|
|
19
|
+
|
|
20
|
+
// Content padding
|
|
21
|
+
--qdadm-content-padding: 1.5rem;
|
|
22
|
+
--qdadm-content-padding-mobile: 1rem;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ============================================
|
|
26
|
+
// App Layout Responsive
|
|
27
|
+
// ============================================
|
|
28
|
+
|
|
29
|
+
.app-layout {
|
|
30
|
+
// Tablet: reduce sidebar width
|
|
31
|
+
@include tablet-lg {
|
|
32
|
+
--qdadm-sidebar-width: 12rem;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Mobile: sidebar becomes overlay
|
|
36
|
+
@include tablet {
|
|
37
|
+
--qdadm-sidebar-width: 80vw;
|
|
38
|
+
max-width: 280px;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Sidebar responsive behavior
|
|
43
|
+
.sidebar {
|
|
44
|
+
transition: transform var(--qdadm-sidebar-transition),
|
|
45
|
+
width var(--qdadm-sidebar-transition);
|
|
46
|
+
|
|
47
|
+
@include tablet {
|
|
48
|
+
transform: translateX(-100%);
|
|
49
|
+
position: fixed;
|
|
50
|
+
z-index: 1000;
|
|
51
|
+
box-shadow: none;
|
|
52
|
+
|
|
53
|
+
&.sidebar--open {
|
|
54
|
+
transform: translateX(0);
|
|
55
|
+
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Main content responsive
|
|
61
|
+
.main-content {
|
|
62
|
+
transition: margin-left var(--qdadm-sidebar-transition);
|
|
63
|
+
|
|
64
|
+
@include tablet {
|
|
65
|
+
margin-left: 0 !important;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Mobile overlay when sidebar open
|
|
70
|
+
.sidebar-overlay {
|
|
71
|
+
display: none;
|
|
72
|
+
position: fixed;
|
|
73
|
+
inset: 0;
|
|
74
|
+
background: rgba(0, 0, 0, 0.5);
|
|
75
|
+
z-index: 99;
|
|
76
|
+
opacity: 0;
|
|
77
|
+
transition: opacity 0.25s;
|
|
78
|
+
|
|
79
|
+
@include tablet {
|
|
80
|
+
display: block;
|
|
81
|
+
pointer-events: none;
|
|
82
|
+
|
|
83
|
+
&.sidebar-overlay--visible {
|
|
84
|
+
opacity: 1;
|
|
85
|
+
pointer-events: auto;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Hamburger menu button (hidden on desktop)
|
|
91
|
+
.hamburger-btn {
|
|
92
|
+
display: none !important;
|
|
93
|
+
|
|
94
|
+
@include tablet {
|
|
95
|
+
display: flex !important;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ============================================
|
|
100
|
+
// Page Content Responsive
|
|
101
|
+
// ============================================
|
|
102
|
+
|
|
103
|
+
.page-content {
|
|
104
|
+
@include tablet {
|
|
105
|
+
padding: var(--qdadm-content-padding-mobile);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Page header responsive
|
|
110
|
+
.page-header {
|
|
111
|
+
@include tablet {
|
|
112
|
+
flex-direction: column;
|
|
113
|
+
gap: 1rem;
|
|
114
|
+
align-items: flex-start !important;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.page-header-actions {
|
|
118
|
+
@include tablet {
|
|
119
|
+
width: 100%;
|
|
120
|
+
justify-content: flex-start;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ============================================
|
|
126
|
+
// DataTable Responsive
|
|
127
|
+
// ============================================
|
|
128
|
+
|
|
129
|
+
.p-datatable {
|
|
130
|
+
@include tablet {
|
|
131
|
+
// Horizontal scroll for table
|
|
132
|
+
.p-datatable-wrapper {
|
|
133
|
+
overflow-x: auto;
|
|
134
|
+
-webkit-overflow-scrolling: touch;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Minimum width to prevent squishing
|
|
138
|
+
.p-datatable-table {
|
|
139
|
+
min-width: 600px;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@include mobile {
|
|
144
|
+
.p-datatable-table {
|
|
145
|
+
min-width: 500px;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ============================================
|
|
151
|
+
// Filter Bar Responsive
|
|
152
|
+
// ============================================
|
|
153
|
+
|
|
154
|
+
.filter-bar {
|
|
155
|
+
@include tablet {
|
|
156
|
+
flex-wrap: wrap;
|
|
157
|
+
gap: 0.75rem;
|
|
158
|
+
|
|
159
|
+
.filter-bar-search {
|
|
160
|
+
width: 100%;
|
|
161
|
+
order: -1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.filter-bar-filters {
|
|
165
|
+
flex-wrap: wrap;
|
|
166
|
+
gap: 0.5rem;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.filter-bar-item {
|
|
170
|
+
min-width: calc(50% - 0.25rem);
|
|
171
|
+
flex: 1 1 auto;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@include mobile {
|
|
176
|
+
.filter-bar-item {
|
|
177
|
+
min-width: 100%;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ============================================
|
|
183
|
+
// Form Layout Responsive
|
|
184
|
+
// ============================================
|
|
185
|
+
|
|
186
|
+
.form-grid {
|
|
187
|
+
@include tablet {
|
|
188
|
+
grid-template-columns: 1fr !important;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.form-actions {
|
|
193
|
+
@include mobile {
|
|
194
|
+
flex-direction: column;
|
|
195
|
+
|
|
196
|
+
.p-button {
|
|
197
|
+
width: 100%;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ============================================
|
|
203
|
+
// Cards Grid Responsive
|
|
204
|
+
// ============================================
|
|
205
|
+
|
|
206
|
+
.cards-grid {
|
|
207
|
+
@include tablet {
|
|
208
|
+
grid-template-columns: repeat(2, 1fr);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
@include mobile {
|
|
212
|
+
grid-template-columns: 1fr;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ============================================
|
|
217
|
+
// Utility Classes
|
|
218
|
+
// ============================================
|
|
219
|
+
|
|
220
|
+
.hide-mobile {
|
|
221
|
+
@include tablet {
|
|
222
|
+
display: none !important;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.hide-desktop {
|
|
227
|
+
display: none !important;
|
|
228
|
+
@include tablet {
|
|
229
|
+
display: block !important;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.hide-mobile-inline {
|
|
234
|
+
@include tablet {
|
|
235
|
+
display: none !important;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.show-mobile-inline {
|
|
240
|
+
display: none !important;
|
|
241
|
+
@include tablet {
|
|
242
|
+
display: inline !important;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* qdadm - Styles entry point
|
|
3
|
+
*
|
|
4
|
+
* Import this file in your main.js:
|
|
5
|
+
* import 'qdadm/styles'
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/* Main styles */
|
|
9
|
+
@use './main.css';
|
|
10
|
+
|
|
11
|
+
/* Component-specific styles */
|
|
12
|
+
@use './_alerts.css';
|
|
13
|
+
@use './_code.css';
|
|
14
|
+
@use './_dialogs.css';
|
|
15
|
+
@use './_markdown.css';
|
|
16
|
+
@use './_show-pages.css';
|
|
17
|
+
|
|
18
|
+
/* Responsive styles (must be last for specificity) */
|
|
19
|
+
@use './responsive';
|
package/src/styles/index.css
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* qdadm - Styles entry point
|
|
3
|
-
*
|
|
4
|
-
* Import this file in your main.js:
|
|
5
|
-
* import 'qdadm/styles'
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/* Main styles */
|
|
9
|
-
@import './main.css';
|
|
10
|
-
|
|
11
|
-
/* Component-specific styles */
|
|
12
|
-
@import './_alerts.css';
|
|
13
|
-
@import './_code.css';
|
|
14
|
-
@import './_dialogs.css';
|
|
15
|
-
@import './_markdown.css';
|
|
16
|
-
@import './_show-pages.css';
|