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.
- package/dist/cli.js +96 -18
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +160 -1
- package/dist/index.js +283 -126
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +172 -2
- package/dist/server.js +581 -1
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +252 -2
- package/dist/worker.js +588 -1
- package/dist/worker.js.map +1 -1
- package/features/admin-page/feature.json +29 -0
- package/features/admin-page/src/AdminPage.tsx +292 -0
- package/features/admin-page/src/admin-schema.ts +20 -0
- package/features/ai-chat/feature.json +41 -0
- package/features/ai-chat/src/AiChatPage.tsx +692 -0
- package/features/ai-chat/src/ChatPanel.tsx +1098 -0
- package/features/ai-chat/src/ai-chat-schema.ts +11 -0
- package/features/canvas/feature.json +45 -0
- package/features/canvas/src/CanvasEditorPage.tsx +87 -0
- package/features/canvas/src/CanvasListPage.tsx +202 -0
- package/features/canvas/src/canvas-schema.ts +33 -0
- package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
- package/features/canvas/src/components/CanvasView.tsx +369 -0
- package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
- package/features/cron/feature.json +33 -0
- package/features/cron/src/CronLogPage.tsx +100 -0
- package/features/cron/src/cron.ts +25 -0
- package/features/docs/feature.json +96 -0
- package/features/docs/src/DocsEditorPage.tsx +1012 -0
- package/features/docs/src/DocsListPage.tsx +951 -0
- package/features/docs/src/DocsOutlinePanel.tsx +68 -0
- package/features/docs/src/DocsPresence.tsx +138 -0
- package/features/docs/src/InviteDialog.tsx +336 -0
- package/features/docs/src/LibrarySidebar.tsx +464 -0
- package/features/docs/src/docs-favorites.ts +18 -0
- package/features/docs/src/docs-library-types.ts +15 -0
- package/features/docs/src/docs-schema.ts +80 -0
- package/features/docs/src/docs-ui.css +309 -0
- package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
- package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
- package/features/docs/src/editor/useDocEditor.ts +206 -0
- package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
- package/features/file-attachments/feature.json +42 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
- package/features/file-attachments/src/attachments-constants.ts +9 -0
- package/features/file-attachments/src/attachments-schema.ts +45 -0
- package/features/file-attachments/src/useFilePreview.ts +471 -0
- package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
- package/features/file-manager/feature.json +24 -0
- package/features/file-manager/src/FileManagerPage.tsx +330 -0
- package/features/integration-test/feature.json +18 -0
- package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
- package/features/integration-test/src/integration.spec.ts +42 -0
- package/features/items/feature.json +33 -0
- package/features/items/src/ItemsPage.tsx +325 -0
- package/features/items/src/items-constants.ts +10 -0
- package/features/items/src/items-schema.ts +27 -0
- package/features/kanban/feature.json +18 -0
- package/features/kanban/src/Kanban.tsx +283 -0
- package/features/landing/feature.json +62 -0
- package/features/landing/src/LandingPage.tsx +627 -0
- package/features/landing/src/primitives.tsx +389 -0
- package/features/landing/src/sections/FAQSection.tsx +110 -0
- package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
- package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
- package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
- package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
- package/features/landing/src/sections/StatsSection.tsx +31 -0
- package/features/landing/src/sections/TeamSection.tsx +86 -0
- package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
- package/features/landing/src/sections/VideoSection.tsx +71 -0
- package/features/leaderboard/feature.json +33 -0
- package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
- package/features/leaderboard/src/leaderboard-constants.ts +21 -0
- package/features/leaderboard/src/leaderboard-schema.ts +36 -0
- package/features/messaging/feature.json +111 -0
- package/features/messaging/src/ChatMultiPage.tsx +300 -0
- package/features/messaging/src/ChatPage.tsx +75 -0
- package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
- package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
- package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
- package/features/messaging/src/components/chat/MessageList.tsx +248 -0
- package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
- package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
- package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
- package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
- package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
- package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
- package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
- package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
- package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
- package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
- package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
- package/features/messaging/src/hooks/useChatChannel.ts +64 -0
- package/features/messaging/src/hooks/useLongPress.ts +74 -0
- package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
- package/features/messaging/src/messaging-schema.ts +25 -0
- package/features/presence-test/feature.json +21 -0
- package/features/presence-test/src/PresenceTestPage.tsx +120 -0
- package/features/sidebar/feature.json +19 -0
- package/features/sidebar/src/AppSidebar.tsx +558 -0
- package/features/tasks/feature.json +33 -0
- package/features/tasks/src/TasksPage.tsx +598 -0
- package/features/tasks/src/tasks-constants.ts +43 -0
- package/features/tasks/src/tasks-schema.ts +56 -0
- package/features/testing/feature.json +49 -0
- package/features/testing/src/TestPage.tsx +145 -0
- package/features/testing/src/cpu-burn-action.ts +51 -0
- package/features/testing/src/subrequest-burn-action.ts +53 -0
- package/features/testing/src/test-schema.ts +23 -0
- package/features/topbar/feature.json +18 -0
- package/features/topbar/src/Topbar.tsx +353 -0
- package/features/tree/feature.json +26 -0
- package/features/tree/src/TreeSidebar.tsx +607 -0
- package/features/tree/src/tree-constants.ts +68 -0
- package/features/tree/src/tree-hooks.ts +258 -0
- package/package.json +4 -2
- 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
|
+
}
|