deepspace 0.3.9 → 0.3.11

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 (120) hide show
  1. package/dist/cli.js +96 -18
  2. package/dist/cli.js.map +1 -1
  3. package/dist/index.d.ts +160 -1
  4. package/dist/index.js +283 -126
  5. package/dist/index.js.map +1 -1
  6. package/dist/server.d.ts +172 -2
  7. package/dist/server.js +581 -1
  8. package/dist/server.js.map +1 -1
  9. package/dist/worker.d.ts +252 -2
  10. package/dist/worker.js +588 -1
  11. package/dist/worker.js.map +1 -1
  12. package/features/admin-page/feature.json +29 -0
  13. package/features/admin-page/src/AdminPage.tsx +292 -0
  14. package/features/admin-page/src/admin-schema.ts +20 -0
  15. package/features/ai-chat/feature.json +41 -0
  16. package/features/ai-chat/src/AiChatPage.tsx +692 -0
  17. package/features/ai-chat/src/ChatPanel.tsx +1098 -0
  18. package/features/ai-chat/src/ai-chat-schema.ts +11 -0
  19. package/features/canvas/feature.json +45 -0
  20. package/features/canvas/src/CanvasEditorPage.tsx +87 -0
  21. package/features/canvas/src/CanvasListPage.tsx +202 -0
  22. package/features/canvas/src/canvas-schema.ts +33 -0
  23. package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
  24. package/features/canvas/src/components/CanvasView.tsx +369 -0
  25. package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
  26. package/features/cron/feature.json +33 -0
  27. package/features/cron/src/CronLogPage.tsx +100 -0
  28. package/features/cron/src/cron.ts +25 -0
  29. package/features/docs/feature.json +96 -0
  30. package/features/docs/src/DocsEditorPage.tsx +1012 -0
  31. package/features/docs/src/DocsListPage.tsx +951 -0
  32. package/features/docs/src/DocsOutlinePanel.tsx +68 -0
  33. package/features/docs/src/DocsPresence.tsx +138 -0
  34. package/features/docs/src/InviteDialog.tsx +336 -0
  35. package/features/docs/src/LibrarySidebar.tsx +464 -0
  36. package/features/docs/src/docs-favorites.ts +18 -0
  37. package/features/docs/src/docs-library-types.ts +15 -0
  38. package/features/docs/src/docs-schema.ts +80 -0
  39. package/features/docs/src/docs-ui.css +309 -0
  40. package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
  41. package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
  42. package/features/docs/src/editor/useDocEditor.ts +206 -0
  43. package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
  44. package/features/file-attachments/feature.json +42 -0
  45. package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
  46. package/features/file-attachments/src/attachments-constants.ts +9 -0
  47. package/features/file-attachments/src/attachments-schema.ts +45 -0
  48. package/features/file-attachments/src/useFilePreview.ts +471 -0
  49. package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
  50. package/features/file-manager/feature.json +24 -0
  51. package/features/file-manager/src/FileManagerPage.tsx +330 -0
  52. package/features/integration-test/feature.json +18 -0
  53. package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
  54. package/features/integration-test/src/integration.spec.ts +42 -0
  55. package/features/items/feature.json +33 -0
  56. package/features/items/src/ItemsPage.tsx +325 -0
  57. package/features/items/src/items-constants.ts +10 -0
  58. package/features/items/src/items-schema.ts +27 -0
  59. package/features/kanban/feature.json +18 -0
  60. package/features/kanban/src/Kanban.tsx +283 -0
  61. package/features/landing/feature.json +62 -0
  62. package/features/landing/src/LandingPage.tsx +627 -0
  63. package/features/landing/src/primitives.tsx +389 -0
  64. package/features/landing/src/sections/FAQSection.tsx +110 -0
  65. package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
  66. package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
  67. package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
  68. package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
  69. package/features/landing/src/sections/StatsSection.tsx +31 -0
  70. package/features/landing/src/sections/TeamSection.tsx +86 -0
  71. package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
  72. package/features/landing/src/sections/VideoSection.tsx +71 -0
  73. package/features/leaderboard/feature.json +33 -0
  74. package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
  75. package/features/leaderboard/src/leaderboard-constants.ts +21 -0
  76. package/features/leaderboard/src/leaderboard-schema.ts +36 -0
  77. package/features/messaging/feature.json +111 -0
  78. package/features/messaging/src/ChatMultiPage.tsx +300 -0
  79. package/features/messaging/src/ChatPage.tsx +75 -0
  80. package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
  81. package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
  82. package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
  83. package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
  84. package/features/messaging/src/components/chat/MessageList.tsx +248 -0
  85. package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
  86. package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
  87. package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
  88. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
  89. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
  90. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
  91. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
  92. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
  93. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
  94. package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
  95. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
  96. package/features/messaging/src/hooks/useChatChannel.ts +64 -0
  97. package/features/messaging/src/hooks/useLongPress.ts +74 -0
  98. package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
  99. package/features/messaging/src/messaging-schema.ts +25 -0
  100. package/features/presence-test/feature.json +21 -0
  101. package/features/presence-test/src/PresenceTestPage.tsx +120 -0
  102. package/features/sidebar/feature.json +19 -0
  103. package/features/sidebar/src/AppSidebar.tsx +558 -0
  104. package/features/tasks/feature.json +33 -0
  105. package/features/tasks/src/TasksPage.tsx +598 -0
  106. package/features/tasks/src/tasks-constants.ts +43 -0
  107. package/features/tasks/src/tasks-schema.ts +56 -0
  108. package/features/testing/feature.json +49 -0
  109. package/features/testing/src/TestPage.tsx +145 -0
  110. package/features/testing/src/cpu-burn-action.ts +51 -0
  111. package/features/testing/src/subrequest-burn-action.ts +53 -0
  112. package/features/testing/src/test-schema.ts +23 -0
  113. package/features/topbar/feature.json +18 -0
  114. package/features/topbar/src/Topbar.tsx +353 -0
  115. package/features/tree/feature.json +26 -0
  116. package/features/tree/src/TreeSidebar.tsx +607 -0
  117. package/features/tree/src/tree-constants.ts +68 -0
  118. package/features/tree/src/tree-hooks.ts +258 -0
  119. package/package.json +4 -2
  120. package/scripts/add-feature.cjs +654 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * PresenceTestPage — exercises real-time presence via PresenceRoom DO.
3
+ *
4
+ * Used by Playwright e2e tests to verify multi-user presence tracking.
5
+ * Connects to a shared presence scope and displays peers + state updates.
6
+ */
7
+
8
+ import { useState, useCallback } from 'react'
9
+ import { usePresenceRoom } from 'deepspace'
10
+ import { useAuth } from 'deepspace'
11
+
12
+ const SCOPE_ID = 'test-presence-room'
13
+
14
+ export default function PresenceTestPage() {
15
+ const { isSignedIn } = useAuth()
16
+ const { peers, connected, updateState } = usePresenceRoom(SCOPE_ID)
17
+ const [cursorX, setCursorX] = useState(0)
18
+ const [cursorY, setCursorY] = useState(0)
19
+
20
+ const sendCursor = useCallback(() => {
21
+ const x = Math.round(Math.random() * 1000)
22
+ const y = Math.round(Math.random() * 1000)
23
+ setCursorX(x)
24
+ setCursorY(y)
25
+ updateState({ cursor: { x, y } })
26
+ }, [updateState])
27
+
28
+ const sendTyping = useCallback(() => {
29
+ updateState({ typing: true })
30
+ }, [updateState])
31
+
32
+ const sendStopTyping = useCallback(() => {
33
+ updateState({ typing: false })
34
+ }, [updateState])
35
+
36
+ return (
37
+ <div className="mx-auto max-w-3xl p-8" data-testid="presence-test-page">
38
+ <div className="mb-8 rounded-lg border border-border bg-card p-4">
39
+ <h2 className="text-lg font-semibold text-foreground mb-2">Connection</h2>
40
+ <div className="space-y-1 text-sm">
41
+ <div>Signed in: <span data-testid="presence-signed-in">{String(isSignedIn)}</span></div>
42
+ <div>Connected: <span data-testid="presence-connected">{String(connected)}</span></div>
43
+ <div>Scope: <span data-testid="presence-scope">{SCOPE_ID}</span></div>
44
+ <div>Peer count: <span data-testid="presence-peer-count">{peers.length}</span></div>
45
+ </div>
46
+ </div>
47
+
48
+ <div className="mb-8 rounded-lg border border-border bg-card p-4">
49
+ <h2 className="text-lg font-semibold text-foreground mb-2">Actions</h2>
50
+ <div className="flex flex-wrap gap-2">
51
+ <button
52
+ data-testid="presence-send-cursor"
53
+ onClick={sendCursor}
54
+ className="rounded bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90"
55
+ >
56
+ Send Cursor
57
+ </button>
58
+ <button
59
+ data-testid="presence-send-typing"
60
+ onClick={sendTyping}
61
+ className="rounded bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90"
62
+ >
63
+ Start Typing
64
+ </button>
65
+ <button
66
+ data-testid="presence-stop-typing"
67
+ onClick={sendStopTyping}
68
+ className="rounded bg-secondary px-3 py-1.5 text-sm text-secondary-foreground hover:opacity-90"
69
+ >
70
+ Stop Typing
71
+ </button>
72
+ </div>
73
+ <div className="mt-2 text-xs text-muted-foreground">
74
+ Last cursor: <span data-testid="presence-local-cursor">{cursorX},{cursorY}</span>
75
+ </div>
76
+ </div>
77
+
78
+ <div className="rounded-lg border border-border bg-card p-4">
79
+ <h2 className="text-lg font-semibold text-foreground mb-2">
80
+ Peers ({peers.length})
81
+ </h2>
82
+ <div data-testid="presence-peers-list" className="space-y-2">
83
+ {peers.map((peer) => (
84
+ <div
85
+ key={peer.userId}
86
+ data-testid={`presence-peer-${peer.userId}`}
87
+ className="flex items-center justify-between rounded border border-border px-3 py-2"
88
+ >
89
+ <div className="text-sm">
90
+ <span className="font-medium text-foreground" data-testid={`presence-peer-name-${peer.userId}`}>
91
+ {peer.userName}
92
+ </span>
93
+ <span className="ml-2 text-muted-foreground" data-testid={`presence-peer-email-${peer.userId}`}>
94
+ {peer.userEmail}
95
+ </span>
96
+ </div>
97
+ <div className="text-xs text-muted-foreground space-x-2">
98
+ {peer.state.cursor && (
99
+ <span data-testid={`presence-peer-cursor-${peer.userId}`}>
100
+ cursor: {(peer.state.cursor as { x: number; y: number }).x},{(peer.state.cursor as { x: number; y: number }).y}
101
+ </span>
102
+ )}
103
+ {peer.state.typing !== undefined && (
104
+ <span data-testid={`presence-peer-typing-${peer.userId}`}>
105
+ {peer.state.typing ? 'typing...' : 'idle'}
106
+ </span>
107
+ )}
108
+ </div>
109
+ </div>
110
+ ))}
111
+ {peers.length === 0 && (
112
+ <div data-testid="presence-peers-empty" className="text-sm text-muted-foreground">
113
+ No other peers connected
114
+ </div>
115
+ )}
116
+ </div>
117
+ </div>
118
+ </div>
119
+ )
120
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "id": "sidebar",
3
+ "name": "Sidebar",
4
+ "category": "nav",
5
+ "description": "Collapsible left navigation sidebar with role-based filtering",
6
+ "details": "Animated sidebar with collapsed icon-only mode, expanded mode with labels and user chip, mobile overlay drawer, localStorage persistence, role-based nav filtering via useUser, and active state via react-router-dom.",
7
+ "files": [
8
+ {
9
+ "src": "src/AppSidebar.tsx",
10
+ "dest": "src/components/sidebar/AppSidebar.tsx"
11
+ }
12
+ ],
13
+ "requires": [],
14
+ "instructions": [
15
+ "Import AppSidebar and SidebarMobileHeader into your layout",
16
+ "Configure nav items array with paths, icons, and optional role restrictions"
17
+ ],
18
+ "patterns": []
19
+ }
@@ -0,0 +1,558 @@
1
+ /**
2
+ * AppSidebar -- collapsible left navigation sidebar.
3
+ *
4
+ * Features:
5
+ * - Collapsed (52px): icon-only with right-side tooltips
6
+ * - Expanded (240px): icons + labels + user chip
7
+ * - Mobile (<=768px): overlay drawer with backdrop
8
+ * - Collapse state persisted to localStorage
9
+ * - Role-based nav filtering via useUser()
10
+ * - Active state via react-router-dom useLocation
11
+ */
12
+
13
+ import { useState, useEffect, useCallback, type ReactNode } from 'react'
14
+ import { useLocation, Link } from 'react-router-dom'
15
+ import { PanelLeft, Zap, Menu } from 'lucide-react'
16
+ import { useUser } from 'deepspace'
17
+ import { ROLES, ROLE_CONFIG, type Role } from 'deepspace'
18
+
19
+ // ============================================================================
20
+ // CSS injection
21
+ // ============================================================================
22
+
23
+ const SIDEBAR_CSS = `@layer base {
24
+ .app-shell {
25
+ height: 100%;
26
+ display: flex;
27
+ overflow: hidden;
28
+ background: color-mix(in srgb, var(--color-card) 40%, var(--color-background));
29
+ padding: 8px 8px 8px 0;
30
+ }
31
+ .app-content {
32
+ flex: 1;
33
+ min-width: 0;
34
+ height: 100%;
35
+ display: flex;
36
+ flex-direction: column;
37
+ overflow: hidden;
38
+ background: var(--color-background);
39
+ border-radius: 12px;
40
+ border: 1px solid var(--color-border);
41
+ }
42
+ .sidebar {
43
+ height: 100%;
44
+ display: flex;
45
+ flex-direction: column;
46
+ overflow: hidden;
47
+ flex-shrink: 0;
48
+ transition: width 200ms cubic-bezier(0.16, 1, 0.3, 1);
49
+ }
50
+ .sidebar.collapsed { overflow: visible; }
51
+ .sidebar-logo-row {
52
+ display: flex;
53
+ align-items: center;
54
+ height: 52px;
55
+ padding: 0 8px;
56
+ flex-shrink: 0;
57
+ }
58
+ .sidebar-logo-btn {
59
+ width: 36px;
60
+ height: 36px;
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ flex-shrink: 0;
65
+ border: none;
66
+ background: none;
67
+ border-radius: 8px;
68
+ cursor: pointer;
69
+ padding: 0;
70
+ color: var(--color-primary);
71
+ transition: background 0.15s, color 0.15s;
72
+ }
73
+ .sidebar-logo-btn:hover {
74
+ background: color-mix(in srgb, var(--color-muted) 60%, transparent);
75
+ color: var(--color-foreground);
76
+ }
77
+ .sidebar-logo-text {
78
+ font-size: 15px;
79
+ font-weight: 700;
80
+ color: var(--color-foreground);
81
+ letter-spacing: -0.3px;
82
+ white-space: nowrap;
83
+ margin-left: 4px;
84
+ opacity: 1;
85
+ transition: opacity 150ms ease;
86
+ }
87
+ .sidebar-collapse-btn {
88
+ margin-left: auto;
89
+ width: 28px;
90
+ height: 28px;
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ border: none;
95
+ background: none;
96
+ border-radius: 6px;
97
+ color: var(--color-muted-foreground);
98
+ cursor: pointer;
99
+ flex-shrink: 0;
100
+ transition: color 0.15s, background 0.15s;
101
+ }
102
+ .sidebar-collapse-btn:hover {
103
+ color: var(--color-foreground);
104
+ background: color-mix(in srgb, var(--color-muted) 60%, transparent);
105
+ }
106
+ .sidebar-nav-section {
107
+ display: flex;
108
+ flex-direction: column;
109
+ padding: 8px 8px;
110
+ gap: 2px;
111
+ flex-shrink: 0;
112
+ }
113
+ .sidebar-nav-item {
114
+ display: flex;
115
+ align-items: center;
116
+ height: 36px;
117
+ padding: 0;
118
+ border: none;
119
+ background: none;
120
+ border-radius: 8px;
121
+ color: var(--color-muted-foreground);
122
+ font-size: 13px;
123
+ font-weight: 500;
124
+ cursor: pointer;
125
+ text-decoration: none;
126
+ transition: background 0.15s, color 0.15s;
127
+ white-space: nowrap;
128
+ font-family: inherit;
129
+ overflow: hidden;
130
+ }
131
+ .sidebar-nav-item:hover {
132
+ background: color-mix(in srgb, var(--color-muted) 60%, transparent);
133
+ color: var(--color-foreground);
134
+ }
135
+ .sidebar-nav-item.active {
136
+ background: color-mix(in srgb, var(--color-primary) 15%, transparent);
137
+ color: var(--color-primary);
138
+ font-weight: 600;
139
+ }
140
+ .sidebar-nav-icon {
141
+ width: 36px;
142
+ height: 36px;
143
+ display: flex;
144
+ align-items: center;
145
+ justify-content: center;
146
+ flex-shrink: 0;
147
+ }
148
+ .sidebar-nav-label {
149
+ margin-left: 4px;
150
+ opacity: 1;
151
+ transition: opacity 150ms ease;
152
+ }
153
+ .sidebar-user-section {
154
+ margin-top: auto;
155
+ padding: 8px 8px;
156
+ flex-shrink: 0;
157
+ border-top: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
158
+ }
159
+ .sidebar-user-chip {
160
+ display: flex;
161
+ align-items: center;
162
+ gap: 8px;
163
+ padding: 6px 8px;
164
+ border-radius: 8px;
165
+ overflow: hidden;
166
+ transition: background 0.15s;
167
+ }
168
+ .sidebar-user-avatar {
169
+ width: 28px;
170
+ height: 28px;
171
+ border-radius: 50%;
172
+ flex-shrink: 0;
173
+ object-fit: cover;
174
+ }
175
+ .sidebar-user-avatar-fallback {
176
+ width: 28px;
177
+ height: 28px;
178
+ border-radius: 50%;
179
+ flex-shrink: 0;
180
+ display: flex;
181
+ align-items: center;
182
+ justify-content: center;
183
+ font-size: 11px;
184
+ font-weight: 600;
185
+ background: color-mix(in srgb, var(--color-muted) 60%, transparent);
186
+ color: var(--color-muted-foreground);
187
+ }
188
+ .sidebar-user-info {
189
+ display: flex;
190
+ flex-direction: column;
191
+ min-width: 0;
192
+ opacity: 1;
193
+ transition: opacity 150ms ease;
194
+ }
195
+ .sidebar-user-name {
196
+ font-size: 13px;
197
+ font-weight: 500;
198
+ color: var(--color-foreground);
199
+ white-space: nowrap;
200
+ overflow: hidden;
201
+ text-overflow: ellipsis;
202
+ }
203
+ .sidebar-user-role {
204
+ font-size: 11px;
205
+ color: var(--color-muted-foreground);
206
+ text-transform: capitalize;
207
+ }
208
+ .sidebar.collapsed .sidebar-logo-text,
209
+ .sidebar.collapsed .sidebar-collapse-btn,
210
+ .sidebar.collapsed .sidebar-nav-label { opacity: 0; pointer-events: none; }
211
+ .sidebar.collapsed .sidebar-user-info { opacity: 0; pointer-events: none; }
212
+ .sidebar.collapsed .sidebar-user-chip { padding: 0; justify-content: center; }
213
+ .sidebar.collapsed .sidebar-nav-section { overflow: visible; }
214
+ .sidebar.collapsed .sidebar-nav-item { overflow: visible; }
215
+ .sidebar-tooltip-right { position: relative; }
216
+ .sidebar-tooltip-right::before,
217
+ .sidebar-tooltip-right::after {
218
+ position: absolute;
219
+ opacity: 0;
220
+ visibility: hidden;
221
+ pointer-events: none;
222
+ transition: opacity 0.15s ease, visibility 0.15s ease, transform 0.15s ease;
223
+ z-index: 10100;
224
+ }
225
+ .sidebar-tooltip-right::before {
226
+ content: '';
227
+ top: 50%;
228
+ left: 100%;
229
+ transform: translateY(-50%) translateX(-2px);
230
+ border: 6px solid transparent;
231
+ border-right-color: var(--color-card);
232
+ }
233
+ .sidebar-tooltip-right::after {
234
+ content: attr(data-tooltip);
235
+ top: 50%;
236
+ left: calc(100% + 10px);
237
+ transform: translateY(-50%) translateX(-4px);
238
+ padding: 6px 10px;
239
+ background: var(--color-card);
240
+ color: var(--color-foreground);
241
+ font-size: 12px;
242
+ font-weight: 500;
243
+ white-space: nowrap;
244
+ border-radius: 6px;
245
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
246
+ border: 1px solid var(--color-border);
247
+ }
248
+ .sidebar-tooltip-right:hover::before,
249
+ .sidebar-tooltip-right:hover::after {
250
+ opacity: 1;
251
+ visibility: visible;
252
+ transform: translateY(-50%) translateX(0);
253
+ }
254
+ .sidebar-mobile-header { display: none; }
255
+ .sidebar-mobile-backdrop { display: none; }
256
+ }
257
+ @media (max-width: 768px) {
258
+ .app-shell { padding: 0; }
259
+ .app-content { border-radius: 0; border: none; }
260
+ .sidebar-mobile-header {
261
+ display: flex;
262
+ align-items: center;
263
+ height: 48px;
264
+ padding: 0 12px;
265
+ gap: 12px;
266
+ flex-shrink: 0;
267
+ background: color-mix(in srgb, var(--color-card) 40%, var(--color-background));
268
+ border-bottom: 1px solid var(--color-border);
269
+ }
270
+ .sidebar-mobile-hamburger {
271
+ width: 36px;
272
+ height: 36px;
273
+ display: flex;
274
+ align-items: center;
275
+ justify-content: center;
276
+ border: none;
277
+ background: none;
278
+ border-radius: 8px;
279
+ color: var(--color-muted-foreground);
280
+ cursor: pointer;
281
+ padding: 0;
282
+ flex-shrink: 0;
283
+ transition: color 0.15s, background 0.15s;
284
+ }
285
+ .sidebar-mobile-hamburger:hover {
286
+ color: var(--color-foreground);
287
+ background: var(--color-muted);
288
+ }
289
+ .sidebar-mobile-title {
290
+ font-size: 15px;
291
+ font-weight: 600;
292
+ color: var(--color-foreground);
293
+ }
294
+ .sidebar-mobile-backdrop {
295
+ display: block;
296
+ position: fixed;
297
+ inset: 0;
298
+ z-index: 998;
299
+ background: rgba(0, 0, 0, 0.4);
300
+ animation: sidebarBackdropFadeIn 0.2s ease;
301
+ }
302
+ .sidebar {
303
+ position: fixed;
304
+ top: 0;
305
+ left: 0;
306
+ bottom: 0;
307
+ z-index: 999;
308
+ width: 260px !important;
309
+ background: color-mix(in srgb, var(--color-card) 40%, var(--color-background));
310
+ transform: translateX(-100%);
311
+ transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
312
+ box-shadow: none;
313
+ }
314
+ .sidebar.mobile-open {
315
+ transform: translateX(0);
316
+ box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
317
+ }
318
+ .sidebar.collapsed { width: 260px !important; }
319
+ .sidebar.collapsed .sidebar-logo-text,
320
+ .sidebar.collapsed .sidebar-nav-label { opacity: 1; pointer-events: auto; }
321
+ .sidebar.collapsed .sidebar-user-info { opacity: 1; pointer-events: auto; }
322
+ .sidebar-collapse-btn { display: none; }
323
+ }
324
+ @keyframes sidebarBackdropFadeIn {
325
+ from { opacity: 0; }
326
+ to { opacity: 1; }
327
+ }`
328
+
329
+ let sidebarCssInjected = false
330
+
331
+ function useSidebarCSS() {
332
+ useEffect(() => {
333
+ if (sidebarCssInjected) return
334
+ sidebarCssInjected = true
335
+ const style = document.createElement('style')
336
+ style.textContent = SIDEBAR_CSS
337
+ document.head.appendChild(style)
338
+ return () => {
339
+ document.head.removeChild(style)
340
+ sidebarCssInjected = false
341
+ }
342
+ }, [])
343
+ }
344
+
345
+ // ============================================================================
346
+ // Types
347
+ // ============================================================================
348
+
349
+ export interface SidebarNavItem {
350
+ path: string
351
+ label: string
352
+ icon: ReactNode
353
+ roles: Role[]
354
+ }
355
+
356
+ interface AppSidebarProps {
357
+ appName: string
358
+ navItems: SidebarNavItem[]
359
+ isMobileOpen: boolean
360
+ onMobileClose: () => void
361
+ /** When set and sidebar is expanded, the logo links to this path. */
362
+ logoHref?: string
363
+ }
364
+
365
+ // ============================================================================
366
+ // localStorage helpers
367
+ // ============================================================================
368
+
369
+ const STORAGE_KEY = 'app-sidebar-collapsed'
370
+
371
+ function readCollapsed(): boolean {
372
+ try {
373
+ return localStorage.getItem(STORAGE_KEY) === 'true'
374
+ } catch {
375
+ return false
376
+ }
377
+ }
378
+
379
+ function writeCollapsed(value: boolean): void {
380
+ try {
381
+ localStorage.setItem(STORAGE_KEY, String(value))
382
+ } catch {}
383
+ }
384
+
385
+ // ============================================================================
386
+ // Mobile header
387
+ // ============================================================================
388
+
389
+ interface MobileHeaderProps {
390
+ appName: string
391
+ onOpenMenu: () => void
392
+ }
393
+
394
+ export function SidebarMobileHeader({ appName, onOpenMenu }: MobileHeaderProps) {
395
+ useSidebarCSS()
396
+
397
+ return (
398
+ <div className="sidebar-mobile-header">
399
+ <button
400
+ type="button"
401
+ className="sidebar-mobile-hamburger"
402
+ onClick={onOpenMenu}
403
+ aria-label="Open menu"
404
+ >
405
+ <Menu size={18} />
406
+ </button>
407
+ <span className="sidebar-mobile-title">{appName}</span>
408
+ </div>
409
+ )
410
+ }
411
+
412
+ // ============================================================================
413
+ // AppSidebar
414
+ // ============================================================================
415
+
416
+ export function AppSidebar({ appName, navItems, isMobileOpen, onMobileClose, logoHref }: AppSidebarProps) {
417
+ useSidebarCSS()
418
+
419
+ const location = useLocation()
420
+ const { user } = useUser()
421
+ const isAdmin = user?.role === 'admin'
422
+
423
+ const [isCollapsed, setIsCollapsed] = useState(readCollapsed)
424
+ const [logoHovered, setLogoHovered] = useState(false)
425
+
426
+ const sidebarWidth = isCollapsed ? 52 : 240
427
+
428
+ const userRole = (user?.role ?? ROLES.VIEWER) as Role
429
+ const roleConfig = ROLE_CONFIG[userRole] ?? ROLE_CONFIG[ROLES.VIEWER]
430
+
431
+ const visibleNavItems = isAdmin
432
+ ? navItems
433
+ : navItems.filter(item => item.roles.includes(userRole))
434
+
435
+ const toggleCollapsed = useCallback(() => {
436
+ setIsCollapsed(prev => {
437
+ const next = !prev
438
+ writeCollapsed(next)
439
+ return next
440
+ })
441
+ }, [])
442
+
443
+ // Close mobile drawer on route change
444
+ useEffect(() => {
445
+ onMobileClose()
446
+ }, [location.pathname, onMobileClose])
447
+
448
+ const handleLogoClick = () => {
449
+ if (isMobileOpen) {
450
+ onMobileClose()
451
+ } else if (isCollapsed) {
452
+ toggleCollapsed()
453
+ }
454
+ }
455
+
456
+ const showTooltip = isCollapsed && !isMobileOpen
457
+
458
+ const sidebarClasses = [
459
+ 'sidebar',
460
+ isCollapsed ? 'collapsed' : '',
461
+ isMobileOpen ? 'mobile-open' : '',
462
+ ].filter(Boolean).join(' ')
463
+
464
+ return (
465
+ <>
466
+ {isMobileOpen && (
467
+ <div className="sidebar-mobile-backdrop" onClick={onMobileClose} />
468
+ )}
469
+
470
+ <nav
471
+ className={sidebarClasses}
472
+ style={{ width: sidebarWidth }}
473
+ >
474
+ {/* Logo row */}
475
+ <div className="sidebar-logo-row">
476
+ {isCollapsed && !isMobileOpen ? (
477
+ <button
478
+ type="button"
479
+ className={`sidebar-logo-btn ${showTooltip ? 'sidebar-tooltip-right' : ''}`}
480
+ onClick={handleLogoClick}
481
+ data-tooltip={showTooltip ? 'Open Sidebar' : undefined}
482
+ onMouseEnter={() => setLogoHovered(true)}
483
+ onMouseLeave={() => setLogoHovered(false)}
484
+ >
485
+ {showTooltip && logoHovered ? (
486
+ <PanelLeft size={18} />
487
+ ) : (
488
+ <Zap size={18} />
489
+ )}
490
+ </button>
491
+ ) : logoHref ? (
492
+ <Link to={logoHref} className="sidebar-logo-btn">
493
+ <Zap size={18} />
494
+ </Link>
495
+ ) : (
496
+ <button type="button" className="sidebar-logo-btn" onClick={handleLogoClick}>
497
+ <Zap size={18} />
498
+ </button>
499
+ )}
500
+ {logoHref && !isCollapsed ? (
501
+ <Link to={logoHref} className="sidebar-logo-text">{appName}</Link>
502
+ ) : (
503
+ <span className="sidebar-logo-text">{appName}</span>
504
+ )}
505
+ <button
506
+ type="button"
507
+ className="sidebar-collapse-btn"
508
+ onClick={toggleCollapsed}
509
+ title="Collapse sidebar"
510
+ >
511
+ <PanelLeft size={16} />
512
+ </button>
513
+ </div>
514
+
515
+ {/* Navigation items */}
516
+ <div className="sidebar-nav-section">
517
+ {visibleNavItems.map(item => {
518
+ const isActive = location.pathname === item.path
519
+ return (
520
+ <Link
521
+ key={item.path}
522
+ to={item.path}
523
+ className={`sidebar-nav-item ${isActive ? 'active' : ''} ${showTooltip ? 'sidebar-tooltip-right' : ''}`}
524
+ data-tooltip={showTooltip ? item.label : undefined}
525
+ >
526
+ <span className="sidebar-nav-icon">{item.icon}</span>
527
+ <span className="sidebar-nav-label">{item.label}</span>
528
+ </Link>
529
+ )
530
+ })}
531
+ </div>
532
+
533
+ {/* User chip pinned to bottom */}
534
+ {user && (
535
+ <div className="sidebar-user-section">
536
+ <div className="sidebar-user-chip">
537
+ {user.imageUrl ? (
538
+ <img
539
+ src={user.imageUrl}
540
+ alt=""
541
+ className="sidebar-user-avatar"
542
+ />
543
+ ) : (
544
+ <div className="sidebar-user-avatar-fallback">
545
+ {user.name?.[0]?.toUpperCase() ?? '?'}
546
+ </div>
547
+ )}
548
+ <div className="sidebar-user-info">
549
+ <span className="sidebar-user-name">{user.name}</span>
550
+ <span className="sidebar-user-role">{roleConfig.title}</span>
551
+ </div>
552
+ </div>
553
+ </div>
554
+ )}
555
+ </nav>
556
+ </>
557
+ )
558
+ }