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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.26.2",
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.css"
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
- "vanilla-jsoneditor": "^0.23.0"
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
  }
@@ -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>
@@ -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';
@@ -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';