main
js 206 lines 5.59 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2
3 // This store manages the visibility and state of the main sidebar panel.
4 const model = {
5 isOpen: true,
6 menuOpen: false,
7 rowMenuOpenId: "",
8 rowMenuKind: "chat",
9 rowMenuStyle: {},
10 rowListExtensions: { chat: {}, task: {} },
11 _initialized: false,
12
13 // Centralized collapse state for all sidebar sections (persisted in localStorage)
14 sectionStates: {
15 tasks: false, // default: collapsed
16 chatActions: false, // default: collapsed
17 preferences: false // default: collapsed
18 },
19
20 // Initialize the store by setting up a resize listener
21 // Guard ensures this runs only once, even if called from multiple components
22 init() {
23 if (this._initialized) return;
24 this._initialized = true;
25
26 this.loadSectionStates();
27 this.handleResize();
28 this.resizeHandler = () => this.handleResize();
29 window.addEventListener("resize", this.resizeHandler);
30 },
31
32 // Load section collapse states from localStorage
33 loadSectionStates() {
34 try {
35 const stored = localStorage.getItem('sidebarSections');
36 if (stored) {
37 this.sectionStates = { ...this.sectionStates, ...JSON.parse(stored) };
38 }
39 } catch (e) {
40 console.error('Failed to load sidebar section states', e);
41 }
42 },
43
44 // Persist section states to localStorage
45 persistSectionStates() {
46 try {
47 localStorage.setItem('sidebarSections', JSON.stringify(this.sectionStates));
48 } catch (e) {
49 console.error('Failed to persist section states', e);
50 }
51 },
52
53 // Check if a section should be open (used by x-init in templates)
54 isSectionOpen(name) {
55 return this.sectionStates[name] === true;
56 },
57
58 // Toggle and persist a section's open state (drives Bootstrap programmatically via components)
59 toggleSection(name) {
60 if (!(name in this.sectionStates)) return;
61 this.sectionStates[name] = !this.sectionStates[name];
62 this.persistSectionStates();
63 },
64
65 // Cleanup method for lifecycle management
66 destroy() {
67 if (this.resizeHandler) {
68 window.removeEventListener("resize", this.resizeHandler);
69 this.resizeHandler = null;
70 }
71 this._initialized = false;
72 },
73
74 // Toggle the sidebar's visibility
75 toggle() {
76 this.isOpen = !this.isOpen;
77 },
78
79 // Close the sidebar, e.g., on overlay click on mobile
80 close() {
81 if (this.isMobile()) {
82 this.isOpen = false;
83 }
84 },
85
86 // Handle browser resize to show/hide sidebar based on viewport width
87 handleResize() {
88 if (this.isMobile()) {
89 this.isOpen = false;
90 }
91 this.menuClose();
92 this.rowMenuClose();
93 },
94
95 // Check if the current viewport is mobile
96 isMobile() {
97 return window.innerWidth <= 768;
98 },
99
100 // Dropdown positioning for quick-actions (fixed position to escape overflow:hidden)
101 dropdownStyle: {},
102
103 headOpen() {
104 return this.isOpen || this.menuOpen;
105 },
106
107 menuToggle(triggerElement) {
108 this.menuOpen = !this.menuOpen;
109 if (this.menuOpen) {
110 this.menuPos(triggerElement);
111 }
112 },
113
114 menuClose() {
115 this.menuOpen = false;
116 },
117
118 menuClick(event, panelElement) {
119 if (!this.menuOpen || !panelElement) return;
120 if (!panelElement.contains(event.target)) {
121 this.menuClose();
122 }
123 },
124
125 menuPos(triggerElement) {
126 if (!triggerElement) return;
127 const rect = triggerElement.getBoundingClientRect();
128 const menuWidth = Math.max(rect.width, 180);
129 const viewportPadding = 8;
130 const maxLeft = Math.max(
131 viewportPadding,
132 window.innerWidth - menuWidth - viewportPadding,
133 );
134 this.dropdownStyle = {
135 top: `${rect.bottom + 8}px`,
136 left: `${Math.min(Math.max(rect.left, viewportPadding), maxLeft)}px`,
137 width: `${menuWidth}px`
138 };
139 },
140
141 registerRowListExtension(kind, name, extension) {
142 if (!this.rowListExtensions[kind] || !name) return;
143 this.rowListExtensions = {
144 ...this.rowListExtensions,
145 [kind]: { ...this.rowListExtensions[kind], [name]: extension },
146 };
147 },
148
149 sortRows(kind, rows) {
150 return Object.values(this.rowListExtensions[kind] || {}).reduce(
151 (result, extension) => extension.sort?.(result) || result,
152 [...rows],
153 );
154 },
155
156 hasRowDividerBefore(kind, item, index, rows) {
157 return Object.values(this.rowListExtensions[kind] || {}).some((extension) =>
158 extension.dividerBefore?.(item, index, rows),
159 );
160 },
161
162 rowMenuToggle(id, kind, triggerElement) {
163 if (this.rowMenuOpenId === id) {
164 this.rowMenuClose();
165 return;
166 }
167
168 this.rowMenuOpenId = id;
169 this.rowMenuKind = kind;
170 this.rowMenuStyle = this.rowMenuPos(triggerElement);
171 },
172
173 rowMenuClose() {
174 this.rowMenuOpenId = "";
175 this.rowMenuStyle = {};
176 },
177
178 rowMenuClick(event, menuElement) {
179 if (!this.rowMenuOpenId || menuElement?.contains(event.target)) return;
180 this.rowMenuClose();
181 },
182
183 rowMenuPos(triggerElement) {
184 if (!triggerElement) return {};
185
186 const rect = triggerElement.getBoundingClientRect();
187 const gap = 6;
188 const padding = 8;
189 const menuWidth = 180;
190 const spaceBelow = window.innerHeight - rect.bottom - gap - padding;
191 const spaceAbove = rect.top - gap - padding;
192 const openUp = spaceBelow < 96 && spaceAbove > spaceBelow;
193 const maxLeft = Math.max(padding, window.innerWidth - menuWidth - padding);
194 const left = Math.min(Math.max(rect.right - menuWidth, padding), maxLeft);
195
196 return {
197 left: `${Math.round(left)}px`,
198 right: "auto",
199 top: openUp ? "auto" : `${Math.round(rect.bottom + gap)}px`,
200 bottom: openUp ? `${Math.round(window.innerHeight - rect.top + gap)}px` : "auto",
201 minWidth: `${menuWidth}px`,
202 };
203 },
204 };
205
206 export const store = createStore("sidebar", model);