cynx-lib 1.3.84 → 1.3.85

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 (40) hide show
  1. package/gen.js +77 -77
  2. package/package.json +5 -1
  3. package/ui/components/Drawer.jsx +208 -208
  4. package/ui/components/Pagination.jsx +145 -145
  5. package/ui/components/PrivacyBlur.jsx +603 -603
  6. package/utils/index.cjs +5 -0
  7. package/utils/index.js +22 -20
  8. package/utils/utils/duration.js +139 -139
  9. package/utils/utils/oidc.js +214 -214
  10. package/utils/utils/realtime.js +1634 -1634
  11. package/utils/utils/session.cjs +15 -2
  12. package/utils/utils/session.js +67 -58
  13. package/utils/utils/session_manager.cjs +490 -0
  14. package/utils/utils/session_manager.js +549 -0
  15. package/utils/utils/storage.js +393 -393
  16. package/utils/utils/ui.js +111 -111
  17. package/vue/components/Badge.vue +79 -79
  18. package/vue/components/Button.vue +209 -209
  19. package/vue/components/Checkbox.vue +103 -103
  20. package/vue/components/CircularProgress.vue +383 -383
  21. package/vue/components/Divider.vue +94 -94
  22. package/vue/components/Drawer.vue +243 -243
  23. package/vue/components/InfoBadge.vue +223 -223
  24. package/vue/components/Input.vue +341 -341
  25. package/vue/components/LinearProgress.vue +268 -268
  26. package/vue/components/Modal.vue +226 -226
  27. package/vue/components/Pagination.vue +199 -199
  28. package/vue/components/Select.vue +713 -713
  29. package/vue/components/Sidebar.vue +955 -955
  30. package/vue/components/Skeleton.vue +91 -91
  31. package/vue/components/Spinner.vue +263 -263
  32. package/vue/components/StatusTag.vue +89 -89
  33. package/vue/components/TabBar.vue +176 -176
  34. package/vue/components/Table.vue +388 -388
  35. package/vue/components/TimePicker.vue +399 -399
  36. package/vue/components/Toggle.vue +115 -115
  37. package/vue/components/XTable.vue +119 -119
  38. package/vue/composables/useAutoSkeletonRows.ts +71 -71
  39. package/vue/index.d.ts +67 -67
  40. package/vue/index.js +86 -86
@@ -1,955 +1,955 @@
1
- <script setup>
2
- import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
3
- import {
4
- PanelLeftClose,
5
- PanelLeftOpen,
6
- LogOut,
7
- ChevronDown,
8
- Lock,
9
- } from 'lucide-vue-next';
10
- import Button from './Button.vue';
11
-
12
- const props = defineProps({
13
- brand: {
14
- type: Object,
15
- default: () => ({}),
16
- },
17
- navSections: {
18
- type: Array,
19
- default: () => [],
20
- },
21
- navItems: {
22
- type: Array,
23
- default: () => [],
24
- },
25
- user: {
26
- type: Object,
27
- default: null,
28
- },
29
- collapsible: {
30
- type: Boolean,
31
- default: true,
32
- },
33
- collapsed: {
34
- type: Boolean,
35
- default: undefined,
36
- },
37
- defaultCollapsed: {
38
- type: Boolean,
39
- default: false,
40
- },
41
- defaultSectionsExpanded: {
42
- type: Boolean,
43
- default: false,
44
- },
45
- sectionsCollapsible: {
46
- type: Boolean,
47
- default: true,
48
- },
49
- autoExpandActive: {
50
- type: Boolean,
51
- default: true,
52
- },
53
- modelValue: {
54
- type: Boolean,
55
- default: undefined,
56
- },
57
- storageKey: {
58
- type: String,
59
- default: 'app_sidebar_collapsed',
60
- },
61
- sidebarBg: {
62
- type: String,
63
- default: '',
64
- },
65
- sidebarColor: {
66
- type: String,
67
- default: '',
68
- },
69
- sidebarMutedColor: {
70
- type: String,
71
- default: '',
72
- },
73
- sidebarBorder: {
74
- type: String,
75
- default: '',
76
- },
77
- sidebarStyle: {
78
- type: [Object, String],
79
- default: () => ({}),
80
- },
81
- sidebarClass: {
82
- type: [String, Object, Array],
83
- default: '',
84
- },
85
- activeBg: {
86
- type: String,
87
- default: '',
88
- },
89
- activeColor: {
90
- type: String,
91
- default: '',
92
- },
93
- activeSubBg: {
94
- type: String,
95
- default: '',
96
- },
97
- activeSubColor: {
98
- type: String,
99
- default: '',
100
- },
101
- subItemsIndent: {
102
- type: Number,
103
- default: 12,
104
- },
105
- hasPermission: {
106
- type: Function,
107
- default: () => true,
108
- },
109
- isItemActive: {
110
- type: Function,
111
- default: null,
112
- },
113
- });
114
-
115
- const emit = defineEmits([
116
- 'update:modelValue',
117
- 'update:collapsed',
118
- 'toggle-collapse',
119
- 'toggle-section',
120
- 'logout',
121
- 'lock',
122
- 'item-click',
123
- ]);
124
-
125
- const internalCollapsed = ref(props.defaultCollapsed);
126
- const isDocumentDark = ref(false);
127
-
128
- const updateDarkThemeState = () => {
129
- if (typeof document !== 'undefined') {
130
- const cl = document.documentElement.classList;
131
- isDocumentDark.value = cl.contains('dark') || cl.contains('dark1') || cl.contains('dark2');
132
- }
133
- };
134
-
135
- let darkObserver = null;
136
-
137
- onMounted(() => {
138
- updateDarkThemeState();
139
- if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') {
140
- darkObserver = new MutationObserver(updateDarkThemeState);
141
- darkObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
142
- }
143
-
144
- if (typeof window !== 'undefined' && props.storageKey) {
145
- const saved = localStorage.getItem(props.storageKey);
146
- if (saved !== null) {
147
- internalCollapsed.value = saved === 'true';
148
- } else {
149
- internalCollapsed.value = props.defaultCollapsed;
150
- }
151
- }
152
- });
153
-
154
- onBeforeUnmount(() => {
155
- if (darkObserver) darkObserver.disconnect();
156
- });
157
-
158
- const isCollapsed = computed(() => {
159
- if (typeof props.modelValue === 'boolean') return props.modelValue;
160
- if (typeof props.collapsed === 'boolean') return props.collapsed;
161
- return internalCollapsed.value;
162
- });
163
-
164
- const toggleSidebarCollapse = (e) => {
165
- if (e) {
166
- e.preventDefault();
167
- e.stopPropagation();
168
- }
169
- const next = !isCollapsed.value;
170
- internalCollapsed.value = next;
171
- emit('update:modelValue', next);
172
- emit('update:collapsed', next);
173
- emit('toggle-collapse', next);
174
- if (typeof window !== 'undefined' && props.storageKey) {
175
- localStorage.setItem(props.storageKey, String(next));
176
- }
177
- };
178
-
179
- const expandedSections = ref({});
180
-
181
- const isSectionCollapsible = (sec) => {
182
- if (typeof sec.isCollapsible === 'boolean') return sec.isCollapsible;
183
- if (typeof sec.collapsible === 'boolean') return sec.collapsible;
184
- return props.sectionsCollapsible;
185
- };
186
-
187
- const isSectionExpanded = (sec, secIdx) => {
188
- // If section is configured as not collapsible, it is ALWAYS open/expanded
189
- if (!isSectionCollapsible(sec)) {
190
- return true;
191
- }
192
-
193
- // Check if user manually toggled this section
194
- if (typeof expandedSections.value[secIdx] === 'boolean') {
195
- return expandedSections.value[secIdx];
196
- }
197
-
198
- // Check if any child item is active (auto-expand active section)
199
- if (props.autoExpandActive && props.isItemActive && Array.isArray(sec.items)) {
200
- const hasActiveChild = sec.items.some((item) => props.isItemActive(item));
201
- if (hasActiveChild) {
202
- return true;
203
- }
204
- }
205
-
206
- // Section level defaults
207
- if (typeof sec.defaultExpanded === 'boolean') return sec.defaultExpanded;
208
- if (typeof sec.defaultCollapsed === 'boolean') return !sec.defaultCollapsed;
209
- if (typeof sec.expanded === 'boolean') return sec.expanded;
210
- if (typeof sec.collapsed === 'boolean') return !sec.collapsed;
211
-
212
- // Component level default (default: false / collapsed)
213
- return props.defaultSectionsExpanded;
214
- };
215
-
216
- const toggleSection = (sec, secIdx) => {
217
- if (!isSectionCollapsible(sec)) return;
218
- const current = isSectionExpanded(sec, secIdx);
219
- expandedSections.value[secIdx] = !current;
220
- emit('toggle-section', { section: sec, index: secIdx, expanded: !current });
221
- };
222
-
223
- const sections = computed(() => {
224
- if (Array.isArray(props.navSections) && props.navSections.length > 0) return props.navSections;
225
- if (Array.isArray(props.navItems) && props.navItems.length > 0) return [{ items: props.navItems }];
226
- return [];
227
- });
228
-
229
- function isDark(color) {
230
- if (!color || typeof color !== 'string') return false;
231
- let c = color.trim().toLowerCase();
232
- if (c === 'transparent' || c === 'inherit') return false;
233
- if (c.startsWith('#')) {
234
- let hex = c.slice(1);
235
- if (hex.length === 3 || hex.length === 4) {
236
- hex = hex.split('').slice(0, 3).map((x) => x + x).join('');
237
- }
238
- if (hex.length >= 6) {
239
- const r = parseInt(hex.slice(0, 2), 16);
240
- const g = parseInt(hex.slice(2, 4), 16);
241
- const b = parseInt(hex.slice(4, 6), 16);
242
- const luma = 0.299 * r + 0.587 * g + 0.114 * b;
243
- return luma < 145;
244
- }
245
- }
246
- if (c.includes('dark') || c.includes('black') || c === '#141629' || c === '#18181b') return true;
247
- return false;
248
- }
249
-
250
- const isDarkSidebar = computed(() => {
251
- if (props.sidebarBg) return isDark(props.sidebarBg);
252
- return isDocumentDark.value;
253
- });
254
-
255
- const effectiveSidebarBg = computed(() => {
256
- if (props.sidebarBg) return props.sidebarBg;
257
- return isDarkSidebar.value ? '#141629' : 'var(--sidebar-bg, var(--surface, #ffffff))';
258
- });
259
-
260
- const effectiveSidebarBorder = computed(() => {
261
- if (props.sidebarBorder) return props.sidebarBorder;
262
- return isDarkSidebar.value
263
- ? '1px solid var(--sidebar-border, rgba(255, 255, 255, 0.08))'
264
- : '1px solid var(--sidebar-border, var(--border-light, #f4f4f5))';
265
- });
266
-
267
- const brandTitleColor = computed(() => {
268
- if (props.sidebarColor) return props.sidebarColor;
269
- return isDarkSidebar.value
270
- ? 'var(--sidebar-text, #ffffff)'
271
- : 'var(--sidebar-text, var(--primary-text, #09090b))';
272
- });
273
-
274
- const brandSubtitleColor = computed(() => {
275
- if (props.sidebarMutedColor) return props.sidebarMutedColor;
276
- return isDarkSidebar.value
277
- ? 'var(--sidebar-text-muted, rgba(255, 255, 255, 0.45))'
278
- : 'var(--sidebar-text-muted, var(--muted, #71717a))';
279
- });
280
-
281
- const toggleBtnColor = computed(() => {
282
- return isDarkSidebar.value
283
- ? 'var(--sidebar-text-muted, rgba(255, 255, 255, 0.65))'
284
- : 'var(--sidebar-text-muted, var(--t2, #71717a))';
285
- });
286
-
287
- const sectionHeaderColor = computed(() => {
288
- if (props.sidebarMutedColor) return props.sidebarMutedColor;
289
- return isDarkSidebar.value
290
- ? 'var(--sidebar-text-muted, rgba(255, 255, 255, 0.45))'
291
- : 'var(--sidebar-text-muted, var(--muted, #71717a))';
292
- });
293
-
294
- const inactiveItemColor = computed(() => {
295
- return isDarkSidebar.value
296
- ? 'var(--sidebar-text-muted, rgba(255, 255, 255, 0.65))'
297
- : 'var(--sidebar-text-muted, var(--t2, #71717a))';
298
- });
299
-
300
- const defaultActiveBg = computed(() => {
301
- return isDarkSidebar.value
302
- ? 'var(--sidebar-item-active-bg, rgba(255, 255, 255, 0.12))'
303
- : 'var(--sidebar-item-active-bg, var(--grey-1, #fafafa))';
304
- });
305
-
306
- const defaultActiveColor = computed(() => {
307
- return isDarkSidebar.value
308
- ? 'var(--sidebar-item-active-text, #ffffff)'
309
- : 'var(--sidebar-item-active-text, var(--primary-text, #09090b))';
310
- });
311
-
312
- const userFullNameColor = computed(() => {
313
- return isDarkSidebar.value
314
- ? 'var(--sidebar-text, rgba(255, 255, 255, 0.85))'
315
- : 'var(--sidebar-text-muted, var(--muted, #71717a))';
316
- });
317
-
318
- const userUsernameColor = computed(() => {
319
- return isDarkSidebar.value
320
- ? 'var(--sidebar-text-muted, rgba(255, 255, 255, 0.45))'
321
- : 'var(--sidebar-text-muted, var(--t3, #a1a1aa))';
322
- });
323
-
324
- const handleItemClick = (item, event) => {
325
- emit('item-click', item, event);
326
- if (typeof item.onSelect === 'function') {
327
- item.onSelect(item, event);
328
- }
329
- };
330
- </script>
331
-
332
- <template>
333
- <aside
334
- class="cynx-sidebar"
335
- :class="[
336
- sidebarClass,
337
- {
338
- 'cynx-sidebar--collapsed': isCollapsed,
339
- 'cynx-sidebar--dark': isDarkSidebar,
340
- },
341
- ]"
342
- :style="[
343
- {
344
- width: isCollapsed ? '64px' : '240px',
345
- minWidth: isCollapsed ? '64px' : '240px',
346
- background: effectiveSidebarBg,
347
- borderRight: effectiveSidebarBorder,
348
- },
349
- sidebarStyle,
350
- ]"
351
- >
352
- <!-- Header Slot or Default Brand Header -->
353
- <slot name="header" :is-collapsed="isCollapsed" :toggle-collapse="toggleSidebarCollapse">
354
- <div
355
- class="cynx-sidebar__header"
356
- :style="{
357
- borderBottom: effectiveSidebarBorder,
358
- justifyContent: isCollapsed ? 'center' : 'space-between',
359
- padding: isCollapsed ? '0 8px' : '0 12px 0 16px',
360
- }"
361
- >
362
- <template v-if="!isCollapsed">
363
- <slot name="brand">
364
- <div class="cynx-sidebar__brand">
365
- <img
366
- v-if="brand?.logoUrl"
367
- :src="brand.logoUrl"
368
- alt=""
369
- class="cynx-sidebar__brand-logo"
370
- />
371
- <template v-else>
372
- <div v-if="brand?.icon" class="cynx-sidebar__brand-icon">
373
- <component :is="brand.icon" />
374
- </div>
375
- <div class="cynx-sidebar__brand-text">
376
- <span class="cynx-sidebar__brand-title" :style="{ color: brandTitleColor }">
377
- {{ brand?.title || 'Приложение' }}
378
- </span>
379
- <span
380
- v-if="brand?.subtitle"
381
- class="cynx-sidebar__brand-subtitle"
382
- :style="{ color: brandSubtitleColor }"
383
- >
384
- {{ brand.subtitle }}
385
- </span>
386
- </div>
387
- </template>
388
- </div>
389
- </slot>
390
-
391
- <button
392
- v-if="collapsible"
393
- type="button"
394
- class="cynx-sidebar__toggle-btn"
395
- :style="{ color: toggleBtnColor }"
396
- title="Свернуть меню"
397
- @click="toggleSidebarCollapse"
398
- >
399
- <PanelLeftClose :size="18" />
400
- </button>
401
- </template>
402
-
403
- <template v-else>
404
- <slot
405
- name="brand-collapsed"
406
- :is-collapsed="isCollapsed"
407
- :toggle-collapse="toggleSidebarCollapse"
408
- >
409
- <button
410
- v-if="collapsible"
411
- type="button"
412
- class="cynx-sidebar__toggle-btn"
413
- :style="{ color: toggleBtnColor, width: '36px', height: '36px' }"
414
- title="Развернуть меню"
415
- @click="toggleSidebarCollapse"
416
- >
417
- <PanelLeftOpen :size="18" />
418
- </button>
419
- </slot>
420
- </template>
421
- </div>
422
- </slot>
423
-
424
- <!-- Sidebar Navigation -->
425
- <nav class="cynx-sidebar__nav" :style="{ padding: isCollapsed ? '12px 8px' : '12px' }">
426
- <template v-for="(sec, secIdx) in sections" :key="secIdx">
427
- <div
428
- v-if="sec.items && sec.items.filter((it) => !it.permission || hasPermission(it.permission)).length > 0"
429
- class="cynx-sidebar__section"
430
- :style="{ marginTop: secIdx > 0 && !isCollapsed ? '6px' : '0px' }"
431
- >
432
- <!-- Section Header: Accordion Button (if collapsible) or Static Title (if not collapsible) -->
433
- <template v-if="sec.title && !isCollapsed">
434
- <button
435
- v-if="isSectionCollapsible(sec)"
436
- type="button"
437
- class="cynx-sidebar__section-title cynx-sidebar__section-title--clickable"
438
- :style="{ color: sectionHeaderColor }"
439
- @click="toggleSection(sec, secIdx)"
440
- >
441
- <span>{{ sec.title }}</span>
442
- <ChevronDown
443
- :size="13"
444
- class="cynx-sidebar__chevron"
445
- :class="{ 'cynx-sidebar__chevron--expanded': isSectionExpanded(sec, secIdx) }"
446
- />
447
- </button>
448
-
449
- <div
450
- v-else
451
- class="cynx-sidebar__section-title cynx-sidebar__section-title--static"
452
- :style="{ color: sectionHeaderColor }"
453
- >
454
- <span>{{ sec.title }}</span>
455
- </div>
456
- </template>
457
-
458
- <!-- Separator when collapsed -->
459
- <div
460
- v-if="sec.title && isCollapsed && secIdx > 0"
461
- class="cynx-sidebar__separator"
462
- :style="{ background: effectiveSidebarBorder }"
463
- />
464
-
465
- <!-- Accordion Grid -->
466
- <div
467
- class="cynx-sidebar__accordion-grid"
468
- :style="{
469
- gridTemplateRows: isCollapsed || isSectionExpanded(sec, secIdx) ? '1fr' : '0fr',
470
- }"
471
- >
472
- <div
473
- class="cynx-sidebar__items-wrapper"
474
- :style="{
475
- paddingTop: sec.title && !isCollapsed ? '2px' : '0px',
476
- paddingLeft: sec.title && !isCollapsed && subItemsIndent ? `${subItemsIndent}px` : '0px',
477
- }"
478
- >
479
- <template
480
- v-for="item in sec.items.filter((it) => !it.permission || hasPermission(it.permission))"
481
- :key="item.to?.name || item.to?.path || item.to || item.label || item.text"
482
- >
483
- <slot
484
- name="item"
485
- :item="item"
486
- :is-collapsed="isCollapsed"
487
- :is-accordion="Boolean(sec.title)"
488
- >
489
- <!-- Router Link Navigation Item -->
490
- <router-link
491
- v-if="item.to"
492
- :to="item.to"
493
- custom
494
- v-slot="{ isActive, isExactActive, href, navigate }"
495
- >
496
- <a
497
- :href="href"
498
- class="cynx-sidebar__link"
499
- :class="{
500
- 'is-active': isItemActive ? isItemActive(item) : (item.exact ? isExactActive : isActive),
501
- }"
502
- :title="isCollapsed ? (item.label || item.text) : undefined"
503
- :style="{
504
- justifyContent: isCollapsed ? 'center' : 'space-between',
505
- padding: isCollapsed
506
- ? '9px 0'
507
- : sec.title
508
- ? '6px 8px'
509
- : '8px 10px',
510
- background: (isItemActive ? isItemActive(item) : (item.exact ? isExactActive : isActive))
511
- ? (item.activeBg || (sec.title ? activeSubBg : null) || activeBg || defaultActiveBg)
512
- : 'transparent',
513
- color: (isItemActive ? isItemActive(item) : (item.exact ? isExactActive : isActive))
514
- ? (item.activeColor || (sec.title ? activeSubColor : null) || activeColor || defaultActiveColor)
515
- : inactiveItemColor,
516
- }"
517
- @click="navigate($event); handleItemClick(item, $event)"
518
- >
519
- <div class="cynx-sidebar__item-content">
520
- <component
521
- :is="item.icon"
522
- v-if="item.icon"
523
- :size="isCollapsed ? 18 : sec.title ? 15 : 16"
524
- class="cynx-sidebar__item-icon"
525
- />
526
- <span v-if="!isCollapsed" class="cynx-sidebar__item-label">
527
- {{ item.label || item.text }}
528
- </span>
529
- </div>
530
-
531
- <span
532
- v-if="!isCollapsed && (typeof item.count === 'number' || item.badge)"
533
- class="cynx-sidebar__item-badge"
534
- :class="{ 'is-active': isItemActive ? isItemActive(item) : (item.exact ? isExactActive : isActive) }"
535
- >
536
- {{ typeof item.count === 'number' ? item.count : item.badge }}
537
- </span>
538
- </a>
539
- </router-link>
540
-
541
- <!-- Normal Click Item -->
542
- <button
543
- v-else
544
- type="button"
545
- class="cynx-sidebar__link"
546
- :style="{
547
- justifyContent: isCollapsed ? 'center' : 'space-between',
548
- padding: isCollapsed ? '9px 0' : sec.title ? '6px 8px' : '8px 10px',
549
- color: inactiveItemColor,
550
- }"
551
- @click="handleItemClick(item, $event)"
552
- >
553
- <div class="cynx-sidebar__item-content">
554
- <component
555
- :is="item.icon"
556
- v-if="item.icon"
557
- :size="isCollapsed ? 18 : sec.title ? 15 : 16"
558
- class="cynx-sidebar__item-icon"
559
- />
560
- <span v-if="!isCollapsed" class="cynx-sidebar__item-label">
561
- {{ item.label || item.text }}
562
- </span>
563
- </div>
564
- </button>
565
- </slot>
566
- </template>
567
- </div>
568
- </div>
569
- </div>
570
- </template>
571
-
572
- <!-- Slot for custom navigation content -->
573
- <slot />
574
- </nav>
575
-
576
- <!-- Footer Slot or Default User Profile -->
577
- <slot
578
- name="footer"
579
- :user="user"
580
- :is-collapsed="isCollapsed"
581
- :on-logout="() => emit('logout')"
582
- :on-lock="() => emit('lock')"
583
- >
584
- <div
585
- v-if="user"
586
- class="cynx-sidebar__footer"
587
- :style="{
588
- padding: isCollapsed ? '12px 8px' : '12px',
589
- borderTop: effectiveSidebarBorder,
590
- }"
591
- >
592
- <template v-if="!isCollapsed">
593
- <div class="cynx-sidebar__user-text" :style="{ color: userFullNameColor }">
594
- <template v-if="user.fullName">
595
- {{ user.fullName }}
596
- <span v-if="user.username" :style="{ color: userUsernameColor }">
597
- ({{ user.username }})
598
- </span>
599
- </template>
600
- <template v-else>
601
- {{ user.username }}
602
- </template>
603
- </div>
604
-
605
- <div class="cynx-sidebar__footer-actions">
606
- <Button
607
- variant="ghost"
608
- size="md"
609
- title="Заблокировать"
610
- class="cynx-sidebar__footer-btn"
611
- @click="emit('lock')"
612
- >
613
- <template #icon>
614
- <Lock :size="13" />
615
- </template>
616
- Блок
617
- </Button>
618
-
619
- <Button
620
- variant="ghost"
621
- size="md"
622
- title="Выйти"
623
- class="cynx-sidebar__footer-btn cynx-sidebar__footer-btn--danger"
624
- @click="emit('logout')"
625
- >
626
- <template #icon>
627
- <LogOut :size="14" />
628
- </template>
629
- Выйти
630
- </Button>
631
- </div>
632
- </template>
633
-
634
- <template v-else>
635
- <div class="cynx-sidebar__collapsed-footer">
636
- <button
637
- type="button"
638
- class="cynx-sidebar__collapsed-btn"
639
- title="Заблокировать экран"
640
- @click="emit('lock')"
641
- >
642
- <Lock :size="16" />
643
- </button>
644
-
645
- <button
646
- type="button"
647
- class="cynx-sidebar__collapsed-btn cynx-sidebar__collapsed-btn--danger"
648
- :title="`Выйти (${user?.username || user?.fullName || ''})`"
649
- @click="emit('logout')"
650
- >
651
- <LogOut :size="17" />
652
- </button>
653
- </div>
654
- </template>
655
- </div>
656
- </slot>
657
- </aside>
658
- </template>
659
-
660
- <style scoped>
661
- .cynx-sidebar {
662
- height: 100vh;
663
- display: flex;
664
- flex-direction: column;
665
- box-sizing: border-box;
666
- user-select: none;
667
- transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1), min-width 0.2s cubic-bezier(0.4, 0, 0.2, 1), background 0.2s ease;
668
- overflow: hidden;
669
- flex-shrink: 0;
670
- font-family: var(--sans, inherit);
671
- }
672
-
673
- .cynx-sidebar__header {
674
- height: 56px;
675
- min-height: 56px;
676
- display: flex;
677
- align-items: center;
678
- box-sizing: border-box;
679
- flex-shrink: 0;
680
- }
681
-
682
- .cynx-sidebar__brand {
683
- flex: 1;
684
- min-width: 0;
685
- display: flex;
686
- align-items: center;
687
- gap: 10px;
688
- overflow: hidden;
689
- }
690
-
691
- .cynx-sidebar__brand-logo {
692
- max-height: 32px;
693
- max-width: 145px;
694
- object-fit: contain;
695
- }
696
-
697
- .cynx-sidebar__brand-icon {
698
- flex-shrink: 0;
699
- }
700
-
701
- .cynx-sidebar__brand-text {
702
- display: flex;
703
- flex-direction: column;
704
- min-width: 0;
705
- overflow: hidden;
706
- }
707
-
708
- .cynx-sidebar__brand-title {
709
- font-weight: 700;
710
- font-size: 0.88rem;
711
- overflow: hidden;
712
- text-overflow: ellipsis;
713
- white-space: nowrap;
714
- }
715
-
716
- .cynx-sidebar__brand-subtitle {
717
- font-size: 0.64rem;
718
- font-weight: 600;
719
- }
720
-
721
- .cynx-sidebar__toggle-btn {
722
- background: transparent;
723
- border: none;
724
- cursor: pointer;
725
- display: flex;
726
- align-items: center;
727
- justify-content: center;
728
- width: 30px;
729
- height: 30px;
730
- border-radius: var(--radxs, 6px);
731
- padding: 0;
732
- transition: background 0.14s, color 0.14s;
733
- flex-shrink: 0;
734
- margin-left: 8px;
735
- }
736
-
737
- .cynx-sidebar__toggle-btn:hover {
738
- background: var(--sidebar-item-hover, var(--grey-1, #fafafa));
739
- color: var(--sidebar-text, var(--primary-text, #09090b));
740
- }
741
-
742
- .cynx-sidebar--dark .cynx-sidebar__toggle-btn:hover {
743
- background: rgba(255, 255, 255, 0.08);
744
- color: #ffffff;
745
- }
746
-
747
- .cynx-sidebar__nav {
748
- flex: 1;
749
- display: flex;
750
- flex-direction: column;
751
- gap: 4px;
752
- overflow-y: auto;
753
- overflow-x: hidden;
754
- }
755
-
756
- .cynx-sidebar__section {
757
- display: flex;
758
- flex-direction: column;
759
- }
760
-
761
- .cynx-sidebar__section-title {
762
- width: 100%;
763
- display: flex;
764
- align-items: center;
765
- justify-content: space-between;
766
- padding: 6px 10px;
767
- border-radius: var(--rads, 8px);
768
- font-size: 0.7rem;
769
- font-weight: 600;
770
- text-transform: uppercase;
771
- letter-spacing: 0.06em;
772
- background: transparent;
773
- border: none;
774
- box-sizing: border-box;
775
- }
776
-
777
- .cynx-sidebar__section-title--clickable {
778
- cursor: pointer;
779
- }
780
-
781
- .cynx-sidebar__section-title--clickable:hover {
782
- background: var(--sidebar-item-hover, var(--grey-1, #fafafa));
783
- }
784
-
785
- .cynx-sidebar--dark .cynx-sidebar__section-title--clickable:hover {
786
- background: rgba(255, 255, 255, 0.04);
787
- }
788
-
789
- .cynx-sidebar__section-title--static {
790
- cursor: default;
791
- }
792
-
793
- .cynx-sidebar__chevron {
794
- transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);
795
- opacity: 0.6;
796
- }
797
-
798
- .cynx-sidebar__chevron--expanded {
799
- transform: rotate(180deg);
800
- }
801
-
802
- .cynx-sidebar__separator {
803
- height: 1px;
804
- margin: 4px 0;
805
- }
806
-
807
- .cynx-sidebar__accordion-grid {
808
- display: grid;
809
- transition: grid-template-rows 0.22s cubic-bezier(0.4, 0, 0.2, 1);
810
- }
811
-
812
- .cynx-sidebar__items-wrapper {
813
- overflow: hidden;
814
- display: flex;
815
- flex-direction: column;
816
- gap: 2px;
817
- }
818
-
819
- .cynx-sidebar__link {
820
- display: flex;
821
- align-items: center;
822
- border-radius: var(--rads, 8px);
823
- font-size: 0.82rem;
824
- font-weight: 600;
825
- border: none;
826
- text-decoration: none;
827
- cursor: pointer;
828
- text-align: left;
829
- font-family: inherit;
830
- transition: all 0.14s;
831
- box-sizing: border-box;
832
- outline: none;
833
- width: 100%;
834
- }
835
-
836
- .cynx-sidebar__link.is-active {
837
- font-weight: 600;
838
- }
839
-
840
- .cynx-sidebar__link:not(.is-active):hover {
841
- background: var(--sidebar-item-hover, var(--grey-1, #fafafa));
842
- color: var(--sidebar-text, var(--primary-text, #09090b));
843
- }
844
-
845
- .cynx-sidebar--dark .cynx-sidebar__link:not(.is-active):hover {
846
- background: rgba(255, 255, 255, 0.06);
847
- color: #ffffff;
848
- }
849
-
850
- .cynx-sidebar__item-content {
851
- display: flex;
852
- align-items: center;
853
- gap: 10px;
854
- min-width: 0;
855
- }
856
-
857
- .cynx-sidebar__item-icon {
858
- flex-shrink: 0;
859
- }
860
-
861
- .cynx-sidebar__item-label {
862
- overflow: hidden;
863
- text-overflow: ellipsis;
864
- white-space: nowrap;
865
- }
866
-
867
- .cynx-sidebar__item-badge {
868
- font-size: 0.68rem;
869
- padding: 1px 6px;
870
- border-radius: var(--radxs, 4px);
871
- font-family: var(--sans, inherit);
872
- font-weight: 600;
873
- background: var(--grey-1, #fafafa);
874
- color: var(--muted, #71717a);
875
- border: 1px solid var(--border-light, #f4f4f5);
876
- }
877
-
878
- .cynx-sidebar__item-badge.is-active {
879
- background: var(--border, #e4e4e7);
880
- color: var(--primary-text, #09090b);
881
- }
882
-
883
- .cynx-sidebar--dark .cynx-sidebar__item-badge {
884
- background: rgba(255, 255, 255, 0.08);
885
- color: rgba(255, 255, 255, 0.6);
886
- border-color: rgba(255, 255, 255, 0.08);
887
- }
888
-
889
- .cynx-sidebar--dark .cynx-sidebar__item-badge.is-active {
890
- background: rgba(255, 255, 255, 0.2);
891
- color: #ffffff;
892
- border: none;
893
- }
894
-
895
- .cynx-sidebar__footer {
896
- flex-shrink: 0;
897
- }
898
-
899
- .cynx-sidebar__user-text {
900
- font-size: 0.72rem;
901
- margin-bottom: 8px;
902
- padding: 0 8px;
903
- white-space: nowrap;
904
- overflow: hidden;
905
- text-overflow: ellipsis;
906
- }
907
-
908
- .cynx-sidebar__footer-actions {
909
- display: flex;
910
- gap: 6px;
911
- }
912
-
913
- .cynx-sidebar__footer-btn {
914
- flex: 1;
915
- justify-content: center;
916
- }
917
-
918
- .cynx-sidebar__footer-btn--danger {
919
- color: var(--danger, #ef4444) !important;
920
- }
921
-
922
- .cynx-sidebar__collapsed-footer {
923
- display: flex;
924
- flex-direction: column;
925
- align-items: center;
926
- gap: 6px;
927
- }
928
-
929
- .cynx-sidebar__collapsed-btn {
930
- width: 36px;
931
- height: 36px;
932
- display: flex;
933
- align-items: center;
934
- justify-content: center;
935
- background: transparent;
936
- border: none;
937
- border-radius: var(--rads, 8px);
938
- color: var(--t2, #71717a);
939
- cursor: pointer;
940
- transition: all 0.14s;
941
- }
942
-
943
- .cynx-sidebar__collapsed-btn:hover {
944
- background: var(--grey-1, #fafafa);
945
- color: var(--primary-text, #09090b);
946
- }
947
-
948
- .cynx-sidebar__collapsed-btn--danger {
949
- color: var(--danger, #ef4444);
950
- }
951
-
952
- .cynx-sidebar__collapsed-btn--danger:hover {
953
- background: var(--danger-bg, rgba(239, 68, 68, 0.1));
954
- }
955
- </style>
1
+ <script setup>
2
+ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
3
+ import {
4
+ PanelLeftClose,
5
+ PanelLeftOpen,
6
+ LogOut,
7
+ ChevronDown,
8
+ Lock,
9
+ } from 'lucide-vue-next';
10
+ import Button from './Button.vue';
11
+
12
+ const props = defineProps({
13
+ brand: {
14
+ type: Object,
15
+ default: () => ({}),
16
+ },
17
+ navSections: {
18
+ type: Array,
19
+ default: () => [],
20
+ },
21
+ navItems: {
22
+ type: Array,
23
+ default: () => [],
24
+ },
25
+ user: {
26
+ type: Object,
27
+ default: null,
28
+ },
29
+ collapsible: {
30
+ type: Boolean,
31
+ default: true,
32
+ },
33
+ collapsed: {
34
+ type: Boolean,
35
+ default: undefined,
36
+ },
37
+ defaultCollapsed: {
38
+ type: Boolean,
39
+ default: false,
40
+ },
41
+ defaultSectionsExpanded: {
42
+ type: Boolean,
43
+ default: false,
44
+ },
45
+ sectionsCollapsible: {
46
+ type: Boolean,
47
+ default: true,
48
+ },
49
+ autoExpandActive: {
50
+ type: Boolean,
51
+ default: true,
52
+ },
53
+ modelValue: {
54
+ type: Boolean,
55
+ default: undefined,
56
+ },
57
+ storageKey: {
58
+ type: String,
59
+ default: 'app_sidebar_collapsed',
60
+ },
61
+ sidebarBg: {
62
+ type: String,
63
+ default: '',
64
+ },
65
+ sidebarColor: {
66
+ type: String,
67
+ default: '',
68
+ },
69
+ sidebarMutedColor: {
70
+ type: String,
71
+ default: '',
72
+ },
73
+ sidebarBorder: {
74
+ type: String,
75
+ default: '',
76
+ },
77
+ sidebarStyle: {
78
+ type: [Object, String],
79
+ default: () => ({}),
80
+ },
81
+ sidebarClass: {
82
+ type: [String, Object, Array],
83
+ default: '',
84
+ },
85
+ activeBg: {
86
+ type: String,
87
+ default: '',
88
+ },
89
+ activeColor: {
90
+ type: String,
91
+ default: '',
92
+ },
93
+ activeSubBg: {
94
+ type: String,
95
+ default: '',
96
+ },
97
+ activeSubColor: {
98
+ type: String,
99
+ default: '',
100
+ },
101
+ subItemsIndent: {
102
+ type: Number,
103
+ default: 12,
104
+ },
105
+ hasPermission: {
106
+ type: Function,
107
+ default: () => true,
108
+ },
109
+ isItemActive: {
110
+ type: Function,
111
+ default: null,
112
+ },
113
+ });
114
+
115
+ const emit = defineEmits([
116
+ 'update:modelValue',
117
+ 'update:collapsed',
118
+ 'toggle-collapse',
119
+ 'toggle-section',
120
+ 'logout',
121
+ 'lock',
122
+ 'item-click',
123
+ ]);
124
+
125
+ const internalCollapsed = ref(props.defaultCollapsed);
126
+ const isDocumentDark = ref(false);
127
+
128
+ const updateDarkThemeState = () => {
129
+ if (typeof document !== 'undefined') {
130
+ const cl = document.documentElement.classList;
131
+ isDocumentDark.value = cl.contains('dark') || cl.contains('dark1') || cl.contains('dark2');
132
+ }
133
+ };
134
+
135
+ let darkObserver = null;
136
+
137
+ onMounted(() => {
138
+ updateDarkThemeState();
139
+ if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') {
140
+ darkObserver = new MutationObserver(updateDarkThemeState);
141
+ darkObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
142
+ }
143
+
144
+ if (typeof window !== 'undefined' && props.storageKey) {
145
+ const saved = localStorage.getItem(props.storageKey);
146
+ if (saved !== null) {
147
+ internalCollapsed.value = saved === 'true';
148
+ } else {
149
+ internalCollapsed.value = props.defaultCollapsed;
150
+ }
151
+ }
152
+ });
153
+
154
+ onBeforeUnmount(() => {
155
+ if (darkObserver) darkObserver.disconnect();
156
+ });
157
+
158
+ const isCollapsed = computed(() => {
159
+ if (typeof props.modelValue === 'boolean') return props.modelValue;
160
+ if (typeof props.collapsed === 'boolean') return props.collapsed;
161
+ return internalCollapsed.value;
162
+ });
163
+
164
+ const toggleSidebarCollapse = (e) => {
165
+ if (e) {
166
+ e.preventDefault();
167
+ e.stopPropagation();
168
+ }
169
+ const next = !isCollapsed.value;
170
+ internalCollapsed.value = next;
171
+ emit('update:modelValue', next);
172
+ emit('update:collapsed', next);
173
+ emit('toggle-collapse', next);
174
+ if (typeof window !== 'undefined' && props.storageKey) {
175
+ localStorage.setItem(props.storageKey, String(next));
176
+ }
177
+ };
178
+
179
+ const expandedSections = ref({});
180
+
181
+ const isSectionCollapsible = (sec) => {
182
+ if (typeof sec.isCollapsible === 'boolean') return sec.isCollapsible;
183
+ if (typeof sec.collapsible === 'boolean') return sec.collapsible;
184
+ return props.sectionsCollapsible;
185
+ };
186
+
187
+ const isSectionExpanded = (sec, secIdx) => {
188
+ // If section is configured as not collapsible, it is ALWAYS open/expanded
189
+ if (!isSectionCollapsible(sec)) {
190
+ return true;
191
+ }
192
+
193
+ // Check if user manually toggled this section
194
+ if (typeof expandedSections.value[secIdx] === 'boolean') {
195
+ return expandedSections.value[secIdx];
196
+ }
197
+
198
+ // Check if any child item is active (auto-expand active section)
199
+ if (props.autoExpandActive && props.isItemActive && Array.isArray(sec.items)) {
200
+ const hasActiveChild = sec.items.some((item) => props.isItemActive(item));
201
+ if (hasActiveChild) {
202
+ return true;
203
+ }
204
+ }
205
+
206
+ // Section level defaults
207
+ if (typeof sec.defaultExpanded === 'boolean') return sec.defaultExpanded;
208
+ if (typeof sec.defaultCollapsed === 'boolean') return !sec.defaultCollapsed;
209
+ if (typeof sec.expanded === 'boolean') return sec.expanded;
210
+ if (typeof sec.collapsed === 'boolean') return !sec.collapsed;
211
+
212
+ // Component level default (default: false / collapsed)
213
+ return props.defaultSectionsExpanded;
214
+ };
215
+
216
+ const toggleSection = (sec, secIdx) => {
217
+ if (!isSectionCollapsible(sec)) return;
218
+ const current = isSectionExpanded(sec, secIdx);
219
+ expandedSections.value[secIdx] = !current;
220
+ emit('toggle-section', { section: sec, index: secIdx, expanded: !current });
221
+ };
222
+
223
+ const sections = computed(() => {
224
+ if (Array.isArray(props.navSections) && props.navSections.length > 0) return props.navSections;
225
+ if (Array.isArray(props.navItems) && props.navItems.length > 0) return [{ items: props.navItems }];
226
+ return [];
227
+ });
228
+
229
+ function isDark(color) {
230
+ if (!color || typeof color !== 'string') return false;
231
+ let c = color.trim().toLowerCase();
232
+ if (c === 'transparent' || c === 'inherit') return false;
233
+ if (c.startsWith('#')) {
234
+ let hex = c.slice(1);
235
+ if (hex.length === 3 || hex.length === 4) {
236
+ hex = hex.split('').slice(0, 3).map((x) => x + x).join('');
237
+ }
238
+ if (hex.length >= 6) {
239
+ const r = parseInt(hex.slice(0, 2), 16);
240
+ const g = parseInt(hex.slice(2, 4), 16);
241
+ const b = parseInt(hex.slice(4, 6), 16);
242
+ const luma = 0.299 * r + 0.587 * g + 0.114 * b;
243
+ return luma < 145;
244
+ }
245
+ }
246
+ if (c.includes('dark') || c.includes('black') || c === '#141629' || c === '#18181b') return true;
247
+ return false;
248
+ }
249
+
250
+ const isDarkSidebar = computed(() => {
251
+ if (props.sidebarBg) return isDark(props.sidebarBg);
252
+ return isDocumentDark.value;
253
+ });
254
+
255
+ const effectiveSidebarBg = computed(() => {
256
+ if (props.sidebarBg) return props.sidebarBg;
257
+ return isDarkSidebar.value ? '#141629' : 'var(--sidebar-bg, var(--surface, #ffffff))';
258
+ });
259
+
260
+ const effectiveSidebarBorder = computed(() => {
261
+ if (props.sidebarBorder) return props.sidebarBorder;
262
+ return isDarkSidebar.value
263
+ ? '1px solid var(--sidebar-border, rgba(255, 255, 255, 0.08))'
264
+ : '1px solid var(--sidebar-border, var(--border-light, #f4f4f5))';
265
+ });
266
+
267
+ const brandTitleColor = computed(() => {
268
+ if (props.sidebarColor) return props.sidebarColor;
269
+ return isDarkSidebar.value
270
+ ? 'var(--sidebar-text, #ffffff)'
271
+ : 'var(--sidebar-text, var(--primary-text, #09090b))';
272
+ });
273
+
274
+ const brandSubtitleColor = computed(() => {
275
+ if (props.sidebarMutedColor) return props.sidebarMutedColor;
276
+ return isDarkSidebar.value
277
+ ? 'var(--sidebar-text-muted, rgba(255, 255, 255, 0.45))'
278
+ : 'var(--sidebar-text-muted, var(--muted, #71717a))';
279
+ });
280
+
281
+ const toggleBtnColor = computed(() => {
282
+ return isDarkSidebar.value
283
+ ? 'var(--sidebar-text-muted, rgba(255, 255, 255, 0.65))'
284
+ : 'var(--sidebar-text-muted, var(--t2, #71717a))';
285
+ });
286
+
287
+ const sectionHeaderColor = computed(() => {
288
+ if (props.sidebarMutedColor) return props.sidebarMutedColor;
289
+ return isDarkSidebar.value
290
+ ? 'var(--sidebar-text-muted, rgba(255, 255, 255, 0.45))'
291
+ : 'var(--sidebar-text-muted, var(--muted, #71717a))';
292
+ });
293
+
294
+ const inactiveItemColor = computed(() => {
295
+ return isDarkSidebar.value
296
+ ? 'var(--sidebar-text-muted, rgba(255, 255, 255, 0.65))'
297
+ : 'var(--sidebar-text-muted, var(--t2, #71717a))';
298
+ });
299
+
300
+ const defaultActiveBg = computed(() => {
301
+ return isDarkSidebar.value
302
+ ? 'var(--sidebar-item-active-bg, rgba(255, 255, 255, 0.12))'
303
+ : 'var(--sidebar-item-active-bg, var(--grey-1, #fafafa))';
304
+ });
305
+
306
+ const defaultActiveColor = computed(() => {
307
+ return isDarkSidebar.value
308
+ ? 'var(--sidebar-item-active-text, #ffffff)'
309
+ : 'var(--sidebar-item-active-text, var(--primary-text, #09090b))';
310
+ });
311
+
312
+ const userFullNameColor = computed(() => {
313
+ return isDarkSidebar.value
314
+ ? 'var(--sidebar-text, rgba(255, 255, 255, 0.85))'
315
+ : 'var(--sidebar-text-muted, var(--muted, #71717a))';
316
+ });
317
+
318
+ const userUsernameColor = computed(() => {
319
+ return isDarkSidebar.value
320
+ ? 'var(--sidebar-text-muted, rgba(255, 255, 255, 0.45))'
321
+ : 'var(--sidebar-text-muted, var(--t3, #a1a1aa))';
322
+ });
323
+
324
+ const handleItemClick = (item, event) => {
325
+ emit('item-click', item, event);
326
+ if (typeof item.onSelect === 'function') {
327
+ item.onSelect(item, event);
328
+ }
329
+ };
330
+ </script>
331
+
332
+ <template>
333
+ <aside
334
+ class="cynx-sidebar"
335
+ :class="[
336
+ sidebarClass,
337
+ {
338
+ 'cynx-sidebar--collapsed': isCollapsed,
339
+ 'cynx-sidebar--dark': isDarkSidebar,
340
+ },
341
+ ]"
342
+ :style="[
343
+ {
344
+ width: isCollapsed ? '64px' : '240px',
345
+ minWidth: isCollapsed ? '64px' : '240px',
346
+ background: effectiveSidebarBg,
347
+ borderRight: effectiveSidebarBorder,
348
+ },
349
+ sidebarStyle,
350
+ ]"
351
+ >
352
+ <!-- Header Slot or Default Brand Header -->
353
+ <slot name="header" :is-collapsed="isCollapsed" :toggle-collapse="toggleSidebarCollapse">
354
+ <div
355
+ class="cynx-sidebar__header"
356
+ :style="{
357
+ borderBottom: effectiveSidebarBorder,
358
+ justifyContent: isCollapsed ? 'center' : 'space-between',
359
+ padding: isCollapsed ? '0 8px' : '0 12px 0 16px',
360
+ }"
361
+ >
362
+ <template v-if="!isCollapsed">
363
+ <slot name="brand">
364
+ <div class="cynx-sidebar__brand">
365
+ <img
366
+ v-if="brand?.logoUrl"
367
+ :src="brand.logoUrl"
368
+ alt=""
369
+ class="cynx-sidebar__brand-logo"
370
+ />
371
+ <template v-else>
372
+ <div v-if="brand?.icon" class="cynx-sidebar__brand-icon">
373
+ <component :is="brand.icon" />
374
+ </div>
375
+ <div class="cynx-sidebar__brand-text">
376
+ <span class="cynx-sidebar__brand-title" :style="{ color: brandTitleColor }">
377
+ {{ brand?.title || 'Приложение' }}
378
+ </span>
379
+ <span
380
+ v-if="brand?.subtitle"
381
+ class="cynx-sidebar__brand-subtitle"
382
+ :style="{ color: brandSubtitleColor }"
383
+ >
384
+ {{ brand.subtitle }}
385
+ </span>
386
+ </div>
387
+ </template>
388
+ </div>
389
+ </slot>
390
+
391
+ <button
392
+ v-if="collapsible"
393
+ type="button"
394
+ class="cynx-sidebar__toggle-btn"
395
+ :style="{ color: toggleBtnColor }"
396
+ title="Свернуть меню"
397
+ @click="toggleSidebarCollapse"
398
+ >
399
+ <PanelLeftClose :size="18" />
400
+ </button>
401
+ </template>
402
+
403
+ <template v-else>
404
+ <slot
405
+ name="brand-collapsed"
406
+ :is-collapsed="isCollapsed"
407
+ :toggle-collapse="toggleSidebarCollapse"
408
+ >
409
+ <button
410
+ v-if="collapsible"
411
+ type="button"
412
+ class="cynx-sidebar__toggle-btn"
413
+ :style="{ color: toggleBtnColor, width: '36px', height: '36px' }"
414
+ title="Развернуть меню"
415
+ @click="toggleSidebarCollapse"
416
+ >
417
+ <PanelLeftOpen :size="18" />
418
+ </button>
419
+ </slot>
420
+ </template>
421
+ </div>
422
+ </slot>
423
+
424
+ <!-- Sidebar Navigation -->
425
+ <nav class="cynx-sidebar__nav" :style="{ padding: isCollapsed ? '12px 8px' : '12px' }">
426
+ <template v-for="(sec, secIdx) in sections" :key="secIdx">
427
+ <div
428
+ v-if="sec.items && sec.items.filter((it) => !it.permission || hasPermission(it.permission)).length > 0"
429
+ class="cynx-sidebar__section"
430
+ :style="{ marginTop: secIdx > 0 && !isCollapsed ? '6px' : '0px' }"
431
+ >
432
+ <!-- Section Header: Accordion Button (if collapsible) or Static Title (if not collapsible) -->
433
+ <template v-if="sec.title && !isCollapsed">
434
+ <button
435
+ v-if="isSectionCollapsible(sec)"
436
+ type="button"
437
+ class="cynx-sidebar__section-title cynx-sidebar__section-title--clickable"
438
+ :style="{ color: sectionHeaderColor }"
439
+ @click="toggleSection(sec, secIdx)"
440
+ >
441
+ <span>{{ sec.title }}</span>
442
+ <ChevronDown
443
+ :size="13"
444
+ class="cynx-sidebar__chevron"
445
+ :class="{ 'cynx-sidebar__chevron--expanded': isSectionExpanded(sec, secIdx) }"
446
+ />
447
+ </button>
448
+
449
+ <div
450
+ v-else
451
+ class="cynx-sidebar__section-title cynx-sidebar__section-title--static"
452
+ :style="{ color: sectionHeaderColor }"
453
+ >
454
+ <span>{{ sec.title }}</span>
455
+ </div>
456
+ </template>
457
+
458
+ <!-- Separator when collapsed -->
459
+ <div
460
+ v-if="sec.title && isCollapsed && secIdx > 0"
461
+ class="cynx-sidebar__separator"
462
+ :style="{ background: effectiveSidebarBorder }"
463
+ />
464
+
465
+ <!-- Accordion Grid -->
466
+ <div
467
+ class="cynx-sidebar__accordion-grid"
468
+ :style="{
469
+ gridTemplateRows: isCollapsed || isSectionExpanded(sec, secIdx) ? '1fr' : '0fr',
470
+ }"
471
+ >
472
+ <div
473
+ class="cynx-sidebar__items-wrapper"
474
+ :style="{
475
+ paddingTop: sec.title && !isCollapsed ? '2px' : '0px',
476
+ paddingLeft: sec.title && !isCollapsed && subItemsIndent ? `${subItemsIndent}px` : '0px',
477
+ }"
478
+ >
479
+ <template
480
+ v-for="item in sec.items.filter((it) => !it.permission || hasPermission(it.permission))"
481
+ :key="item.to?.name || item.to?.path || item.to || item.label || item.text"
482
+ >
483
+ <slot
484
+ name="item"
485
+ :item="item"
486
+ :is-collapsed="isCollapsed"
487
+ :is-accordion="Boolean(sec.title)"
488
+ >
489
+ <!-- Router Link Navigation Item -->
490
+ <router-link
491
+ v-if="item.to"
492
+ :to="item.to"
493
+ custom
494
+ v-slot="{ isActive, isExactActive, href, navigate }"
495
+ >
496
+ <a
497
+ :href="href"
498
+ class="cynx-sidebar__link"
499
+ :class="{
500
+ 'is-active': isItemActive ? isItemActive(item) : (item.exact ? isExactActive : isActive),
501
+ }"
502
+ :title="isCollapsed ? (item.label || item.text) : undefined"
503
+ :style="{
504
+ justifyContent: isCollapsed ? 'center' : 'space-between',
505
+ padding: isCollapsed
506
+ ? '9px 0'
507
+ : sec.title
508
+ ? '6px 8px'
509
+ : '8px 10px',
510
+ background: (isItemActive ? isItemActive(item) : (item.exact ? isExactActive : isActive))
511
+ ? (item.activeBg || (sec.title ? activeSubBg : null) || activeBg || defaultActiveBg)
512
+ : 'transparent',
513
+ color: (isItemActive ? isItemActive(item) : (item.exact ? isExactActive : isActive))
514
+ ? (item.activeColor || (sec.title ? activeSubColor : null) || activeColor || defaultActiveColor)
515
+ : inactiveItemColor,
516
+ }"
517
+ @click="navigate($event); handleItemClick(item, $event)"
518
+ >
519
+ <div class="cynx-sidebar__item-content">
520
+ <component
521
+ :is="item.icon"
522
+ v-if="item.icon"
523
+ :size="isCollapsed ? 18 : sec.title ? 15 : 16"
524
+ class="cynx-sidebar__item-icon"
525
+ />
526
+ <span v-if="!isCollapsed" class="cynx-sidebar__item-label">
527
+ {{ item.label || item.text }}
528
+ </span>
529
+ </div>
530
+
531
+ <span
532
+ v-if="!isCollapsed && (typeof item.count === 'number' || item.badge)"
533
+ class="cynx-sidebar__item-badge"
534
+ :class="{ 'is-active': isItemActive ? isItemActive(item) : (item.exact ? isExactActive : isActive) }"
535
+ >
536
+ {{ typeof item.count === 'number' ? item.count : item.badge }}
537
+ </span>
538
+ </a>
539
+ </router-link>
540
+
541
+ <!-- Normal Click Item -->
542
+ <button
543
+ v-else
544
+ type="button"
545
+ class="cynx-sidebar__link"
546
+ :style="{
547
+ justifyContent: isCollapsed ? 'center' : 'space-between',
548
+ padding: isCollapsed ? '9px 0' : sec.title ? '6px 8px' : '8px 10px',
549
+ color: inactiveItemColor,
550
+ }"
551
+ @click="handleItemClick(item, $event)"
552
+ >
553
+ <div class="cynx-sidebar__item-content">
554
+ <component
555
+ :is="item.icon"
556
+ v-if="item.icon"
557
+ :size="isCollapsed ? 18 : sec.title ? 15 : 16"
558
+ class="cynx-sidebar__item-icon"
559
+ />
560
+ <span v-if="!isCollapsed" class="cynx-sidebar__item-label">
561
+ {{ item.label || item.text }}
562
+ </span>
563
+ </div>
564
+ </button>
565
+ </slot>
566
+ </template>
567
+ </div>
568
+ </div>
569
+ </div>
570
+ </template>
571
+
572
+ <!-- Slot for custom navigation content -->
573
+ <slot />
574
+ </nav>
575
+
576
+ <!-- Footer Slot or Default User Profile -->
577
+ <slot
578
+ name="footer"
579
+ :user="user"
580
+ :is-collapsed="isCollapsed"
581
+ :on-logout="() => emit('logout')"
582
+ :on-lock="() => emit('lock')"
583
+ >
584
+ <div
585
+ v-if="user"
586
+ class="cynx-sidebar__footer"
587
+ :style="{
588
+ padding: isCollapsed ? '12px 8px' : '12px',
589
+ borderTop: effectiveSidebarBorder,
590
+ }"
591
+ >
592
+ <template v-if="!isCollapsed">
593
+ <div class="cynx-sidebar__user-text" :style="{ color: userFullNameColor }">
594
+ <template v-if="user.fullName">
595
+ {{ user.fullName }}
596
+ <span v-if="user.username" :style="{ color: userUsernameColor }">
597
+ ({{ user.username }})
598
+ </span>
599
+ </template>
600
+ <template v-else>
601
+ {{ user.username }}
602
+ </template>
603
+ </div>
604
+
605
+ <div class="cynx-sidebar__footer-actions">
606
+ <Button
607
+ variant="ghost"
608
+ size="md"
609
+ title="Заблокировать"
610
+ class="cynx-sidebar__footer-btn"
611
+ @click="emit('lock')"
612
+ >
613
+ <template #icon>
614
+ <Lock :size="13" />
615
+ </template>
616
+ Блок
617
+ </Button>
618
+
619
+ <Button
620
+ variant="ghost"
621
+ size="md"
622
+ title="Выйти"
623
+ class="cynx-sidebar__footer-btn cynx-sidebar__footer-btn--danger"
624
+ @click="emit('logout')"
625
+ >
626
+ <template #icon>
627
+ <LogOut :size="14" />
628
+ </template>
629
+ Выйти
630
+ </Button>
631
+ </div>
632
+ </template>
633
+
634
+ <template v-else>
635
+ <div class="cynx-sidebar__collapsed-footer">
636
+ <button
637
+ type="button"
638
+ class="cynx-sidebar__collapsed-btn"
639
+ title="Заблокировать экран"
640
+ @click="emit('lock')"
641
+ >
642
+ <Lock :size="16" />
643
+ </button>
644
+
645
+ <button
646
+ type="button"
647
+ class="cynx-sidebar__collapsed-btn cynx-sidebar__collapsed-btn--danger"
648
+ :title="`Выйти (${user?.username || user?.fullName || ''})`"
649
+ @click="emit('logout')"
650
+ >
651
+ <LogOut :size="17" />
652
+ </button>
653
+ </div>
654
+ </template>
655
+ </div>
656
+ </slot>
657
+ </aside>
658
+ </template>
659
+
660
+ <style scoped>
661
+ .cynx-sidebar {
662
+ height: 100vh;
663
+ display: flex;
664
+ flex-direction: column;
665
+ box-sizing: border-box;
666
+ user-select: none;
667
+ transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1), min-width 0.2s cubic-bezier(0.4, 0, 0.2, 1), background 0.2s ease;
668
+ overflow: hidden;
669
+ flex-shrink: 0;
670
+ font-family: var(--sans, inherit);
671
+ }
672
+
673
+ .cynx-sidebar__header {
674
+ height: 56px;
675
+ min-height: 56px;
676
+ display: flex;
677
+ align-items: center;
678
+ box-sizing: border-box;
679
+ flex-shrink: 0;
680
+ }
681
+
682
+ .cynx-sidebar__brand {
683
+ flex: 1;
684
+ min-width: 0;
685
+ display: flex;
686
+ align-items: center;
687
+ gap: 10px;
688
+ overflow: hidden;
689
+ }
690
+
691
+ .cynx-sidebar__brand-logo {
692
+ max-height: 32px;
693
+ max-width: 145px;
694
+ object-fit: contain;
695
+ }
696
+
697
+ .cynx-sidebar__brand-icon {
698
+ flex-shrink: 0;
699
+ }
700
+
701
+ .cynx-sidebar__brand-text {
702
+ display: flex;
703
+ flex-direction: column;
704
+ min-width: 0;
705
+ overflow: hidden;
706
+ }
707
+
708
+ .cynx-sidebar__brand-title {
709
+ font-weight: 700;
710
+ font-size: 0.88rem;
711
+ overflow: hidden;
712
+ text-overflow: ellipsis;
713
+ white-space: nowrap;
714
+ }
715
+
716
+ .cynx-sidebar__brand-subtitle {
717
+ font-size: 0.64rem;
718
+ font-weight: 600;
719
+ }
720
+
721
+ .cynx-sidebar__toggle-btn {
722
+ background: transparent;
723
+ border: none;
724
+ cursor: pointer;
725
+ display: flex;
726
+ align-items: center;
727
+ justify-content: center;
728
+ width: 30px;
729
+ height: 30px;
730
+ border-radius: var(--radxs, 6px);
731
+ padding: 0;
732
+ transition: background 0.14s, color 0.14s;
733
+ flex-shrink: 0;
734
+ margin-left: 8px;
735
+ }
736
+
737
+ .cynx-sidebar__toggle-btn:hover {
738
+ background: var(--sidebar-item-hover, var(--grey-1, #fafafa));
739
+ color: var(--sidebar-text, var(--primary-text, #09090b));
740
+ }
741
+
742
+ .cynx-sidebar--dark .cynx-sidebar__toggle-btn:hover {
743
+ background: rgba(255, 255, 255, 0.08);
744
+ color: #ffffff;
745
+ }
746
+
747
+ .cynx-sidebar__nav {
748
+ flex: 1;
749
+ display: flex;
750
+ flex-direction: column;
751
+ gap: 4px;
752
+ overflow-y: auto;
753
+ overflow-x: hidden;
754
+ }
755
+
756
+ .cynx-sidebar__section {
757
+ display: flex;
758
+ flex-direction: column;
759
+ }
760
+
761
+ .cynx-sidebar__section-title {
762
+ width: 100%;
763
+ display: flex;
764
+ align-items: center;
765
+ justify-content: space-between;
766
+ padding: 6px 10px;
767
+ border-radius: var(--rads, 8px);
768
+ font-size: 0.7rem;
769
+ font-weight: 600;
770
+ text-transform: uppercase;
771
+ letter-spacing: 0.06em;
772
+ background: transparent;
773
+ border: none;
774
+ box-sizing: border-box;
775
+ }
776
+
777
+ .cynx-sidebar__section-title--clickable {
778
+ cursor: pointer;
779
+ }
780
+
781
+ .cynx-sidebar__section-title--clickable:hover {
782
+ background: var(--sidebar-item-hover, var(--grey-1, #fafafa));
783
+ }
784
+
785
+ .cynx-sidebar--dark .cynx-sidebar__section-title--clickable:hover {
786
+ background: rgba(255, 255, 255, 0.04);
787
+ }
788
+
789
+ .cynx-sidebar__section-title--static {
790
+ cursor: default;
791
+ }
792
+
793
+ .cynx-sidebar__chevron {
794
+ transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);
795
+ opacity: 0.6;
796
+ }
797
+
798
+ .cynx-sidebar__chevron--expanded {
799
+ transform: rotate(180deg);
800
+ }
801
+
802
+ .cynx-sidebar__separator {
803
+ height: 1px;
804
+ margin: 4px 0;
805
+ }
806
+
807
+ .cynx-sidebar__accordion-grid {
808
+ display: grid;
809
+ transition: grid-template-rows 0.22s cubic-bezier(0.4, 0, 0.2, 1);
810
+ }
811
+
812
+ .cynx-sidebar__items-wrapper {
813
+ overflow: hidden;
814
+ display: flex;
815
+ flex-direction: column;
816
+ gap: 2px;
817
+ }
818
+
819
+ .cynx-sidebar__link {
820
+ display: flex;
821
+ align-items: center;
822
+ border-radius: var(--rads, 8px);
823
+ font-size: 0.82rem;
824
+ font-weight: 600;
825
+ border: none;
826
+ text-decoration: none;
827
+ cursor: pointer;
828
+ text-align: left;
829
+ font-family: inherit;
830
+ transition: all 0.14s;
831
+ box-sizing: border-box;
832
+ outline: none;
833
+ width: 100%;
834
+ }
835
+
836
+ .cynx-sidebar__link.is-active {
837
+ font-weight: 600;
838
+ }
839
+
840
+ .cynx-sidebar__link:not(.is-active):hover {
841
+ background: var(--sidebar-item-hover, var(--grey-1, #fafafa));
842
+ color: var(--sidebar-text, var(--primary-text, #09090b));
843
+ }
844
+
845
+ .cynx-sidebar--dark .cynx-sidebar__link:not(.is-active):hover {
846
+ background: rgba(255, 255, 255, 0.06);
847
+ color: #ffffff;
848
+ }
849
+
850
+ .cynx-sidebar__item-content {
851
+ display: flex;
852
+ align-items: center;
853
+ gap: 10px;
854
+ min-width: 0;
855
+ }
856
+
857
+ .cynx-sidebar__item-icon {
858
+ flex-shrink: 0;
859
+ }
860
+
861
+ .cynx-sidebar__item-label {
862
+ overflow: hidden;
863
+ text-overflow: ellipsis;
864
+ white-space: nowrap;
865
+ }
866
+
867
+ .cynx-sidebar__item-badge {
868
+ font-size: 0.68rem;
869
+ padding: 1px 6px;
870
+ border-radius: var(--radxs, 4px);
871
+ font-family: var(--sans, inherit);
872
+ font-weight: 600;
873
+ background: var(--grey-1, #fafafa);
874
+ color: var(--muted, #71717a);
875
+ border: 1px solid var(--border-light, #f4f4f5);
876
+ }
877
+
878
+ .cynx-sidebar__item-badge.is-active {
879
+ background: var(--border, #e4e4e7);
880
+ color: var(--primary-text, #09090b);
881
+ }
882
+
883
+ .cynx-sidebar--dark .cynx-sidebar__item-badge {
884
+ background: rgba(255, 255, 255, 0.08);
885
+ color: rgba(255, 255, 255, 0.6);
886
+ border-color: rgba(255, 255, 255, 0.08);
887
+ }
888
+
889
+ .cynx-sidebar--dark .cynx-sidebar__item-badge.is-active {
890
+ background: rgba(255, 255, 255, 0.2);
891
+ color: #ffffff;
892
+ border: none;
893
+ }
894
+
895
+ .cynx-sidebar__footer {
896
+ flex-shrink: 0;
897
+ }
898
+
899
+ .cynx-sidebar__user-text {
900
+ font-size: 0.72rem;
901
+ margin-bottom: 8px;
902
+ padding: 0 8px;
903
+ white-space: nowrap;
904
+ overflow: hidden;
905
+ text-overflow: ellipsis;
906
+ }
907
+
908
+ .cynx-sidebar__footer-actions {
909
+ display: flex;
910
+ gap: 6px;
911
+ }
912
+
913
+ .cynx-sidebar__footer-btn {
914
+ flex: 1;
915
+ justify-content: center;
916
+ }
917
+
918
+ .cynx-sidebar__footer-btn--danger {
919
+ color: var(--danger, #ef4444) !important;
920
+ }
921
+
922
+ .cynx-sidebar__collapsed-footer {
923
+ display: flex;
924
+ flex-direction: column;
925
+ align-items: center;
926
+ gap: 6px;
927
+ }
928
+
929
+ .cynx-sidebar__collapsed-btn {
930
+ width: 36px;
931
+ height: 36px;
932
+ display: flex;
933
+ align-items: center;
934
+ justify-content: center;
935
+ background: transparent;
936
+ border: none;
937
+ border-radius: var(--rads, 8px);
938
+ color: var(--t2, #71717a);
939
+ cursor: pointer;
940
+ transition: all 0.14s;
941
+ }
942
+
943
+ .cynx-sidebar__collapsed-btn:hover {
944
+ background: var(--grey-1, #fafafa);
945
+ color: var(--primary-text, #09090b);
946
+ }
947
+
948
+ .cynx-sidebar__collapsed-btn--danger {
949
+ color: var(--danger, #ef4444);
950
+ }
951
+
952
+ .cynx-sidebar__collapsed-btn--danger:hover {
953
+ background: var(--danger-bg, rgba(239, 68, 68, 0.1));
954
+ }
955
+ </style>