@mostrom/app-shell 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/ralph-loop.local.md +9 -0
- package/README.md +172 -0
- package/bin/init.js +269 -0
- package/bun.lock +401 -0
- package/components.json +28 -0
- package/package.json +74 -0
- package/scripts/publish-npm.sh +202 -0
- package/src/AppShell.tsx +847 -0
- package/src/components/PageHeader.tsx +160 -0
- package/src/components/data-table/README.md +447 -0
- package/src/components/data-table/data-table-preferences.tsx +184 -0
- package/src/components/data-table/data-table-toolbar.tsx +118 -0
- package/src/components/data-table/data-table.tsx +37 -0
- package/src/components/data-table/index.ts +32 -0
- package/src/components/global-header/AllServicesButton.tsx +127 -0
- package/src/components/global-header/CategoriesButton.tsx +120 -0
- package/src/components/global-header/GlobalHeader.tsx +59 -0
- package/src/components/global-header/GlobalHeaderSearch.tsx +57 -0
- package/src/components/global-header/HeaderUtilities.tsx +243 -0
- package/src/components/global-header/ServicesMenu.tsx +246 -0
- package/src/components/layout/AppBreadcrumb.tsx +70 -0
- package/src/components/layout/AppFlashbar.tsx +95 -0
- package/src/components/layout/AppLayout.tsx +271 -0
- package/src/components/layout/AppNavigation.tsx +313 -0
- package/src/components/layout/AppSidebar.tsx +229 -0
- package/src/components/patterns/index.ts +14 -0
- package/src/components/patterns/p-alert-5.tsx +19 -0
- package/src/components/patterns/p-autocomplete-5.tsx +89 -0
- package/src/components/patterns/p-breadcrumb-1.tsx +28 -0
- package/src/components/patterns/p-button-42.tsx +37 -0
- package/src/components/patterns/p-button-51.tsx +14 -0
- package/src/components/patterns/p-button-6.tsx +5 -0
- package/src/components/patterns/p-calendar-1.tsx +18 -0
- package/src/components/patterns/p-card-1.tsx +33 -0
- package/src/components/patterns/p-card-2.tsx +26 -0
- package/src/components/patterns/p-card-5.tsx +31 -0
- package/src/components/patterns/p-collapsible-7.tsx +121 -0
- package/src/components/patterns/p-command-6.tsx +113 -0
- package/src/components/patterns/p-dialog-1.tsx +56 -0
- package/src/components/patterns/p-dropdown-menu-1.tsx +38 -0
- package/src/components/patterns/p-dropdown-menu-11.tsx +122 -0
- package/src/components/patterns/p-dropdown-menu-14.tsx +165 -0
- package/src/components/patterns/p-dropdown-menu-9.tsx +108 -0
- package/src/components/patterns/p-empty-2.tsx +34 -0
- package/src/components/patterns/p-file-upload-1.tsx +72 -0
- package/src/components/patterns/p-filters-1.tsx +666 -0
- package/src/components/patterns/p-frame-2.tsx +26 -0
- package/src/components/patterns/p-tabs-2.tsx +129 -0
- package/src/components/reui/alert.tsx +92 -0
- package/src/components/reui/autocomplete.tsx +343 -0
- package/src/components/reui/badge.tsx +87 -0
- package/src/components/reui/data-grid/data-grid-column-filter.tsx +165 -0
- package/src/components/reui/data-grid/data-grid-column-header.tsx +339 -0
- package/src/components/reui/data-grid/data-grid-column-visibility.tsx +55 -0
- package/src/components/reui/data-grid/data-grid-pagination.tsx +224 -0
- package/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx +260 -0
- package/src/components/reui/data-grid/data-grid-table-dnd.tsx +253 -0
- package/src/components/reui/data-grid/data-grid-table.tsx +639 -0
- package/src/components/reui/data-grid/data-grid.tsx +209 -0
- package/src/components/reui/date-selector.tsx +1330 -0
- package/src/components/reui/filters.tsx +1869 -0
- package/src/components/reui/frame.tsx +134 -0
- package/src/components/reui/index.ts +17 -0
- package/src/components/reui/timeline.tsx +219 -0
- package/src/components/search/Autocomplete.tsx +183 -0
- package/src/components/search/AutocompleteClient.tsx +293 -0
- package/src/components/search/GlobalSearch.tsx +187 -0
- package/src/components/section-drawer/deal-drawer-content.tsx +891 -0
- package/src/components/section-drawer/index.ts +19 -0
- package/src/components/section-drawer/section-drawer.css +665 -0
- package/src/components/section-drawer/section-drawer.tsx +467 -0
- package/src/components/sectioned-list-board/README.md +78 -0
- package/src/components/sectioned-list-board/board-card-content.tsx +340 -0
- package/src/components/sectioned-list-board/date-range-filter.tsx +249 -0
- package/src/components/sectioned-list-board/index.ts +19 -0
- package/src/components/sectioned-list-board/sectioned-list-board.css +564 -0
- package/src/components/sectioned-list-board/sectioned-list-board.tsx +731 -0
- package/src/components/sectioned-list-board/sortable-card.tsx +314 -0
- package/src/components/sectioned-list-board/sortable-section.tsx +319 -0
- package/src/components/sectioned-list-board/types.ts +216 -0
- package/src/components/sectioned-list-table/README.md +80 -0
- package/src/components/sectioned-list-table/index.ts +14 -0
- package/src/components/sectioned-list-table/sectioned-list-table.css +534 -0
- package/src/components/sectioned-list-table/sectioned-list-table.tsx +740 -0
- package/src/components/sectioned-list-table/sortable-column-header.tsx +120 -0
- package/src/components/sectioned-list-table/sortable-row.tsx +420 -0
- package/src/components/sectioned-list-table/sortable-section.tsx +251 -0
- package/src/components/sectioned-list-table/table-cell-content.tsx +129 -0
- package/src/components/sectioned-list-table/types.ts +120 -0
- package/src/components/sectioned-list-table/use-column-preferences.ts +103 -0
- package/src/components/ui/actions-dropdown.tsx +109 -0
- package/src/components/ui/assignee-selector.tsx +209 -0
- package/src/components/ui/avatar.tsx +107 -0
- package/src/components/ui/breadcrumb.tsx +109 -0
- package/src/components/ui/button-group.tsx +83 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/calendar.tsx +220 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/chart.tsx +376 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/collapsible.tsx +33 -0
- package/src/components/ui/command.tsx +182 -0
- package/src/components/ui/context-menu.tsx +250 -0
- package/src/components/ui/create-button-group.tsx +128 -0
- package/src/components/ui/dialog.tsx +156 -0
- package/src/components/ui/drawer.tsx +133 -0
- package/src/components/ui/dropdown-menu.tsx +255 -0
- package/src/components/ui/empty.tsx +104 -0
- package/src/components/ui/field.tsx +248 -0
- package/src/components/ui/form.tsx +165 -0
- package/src/components/ui/index.ts +37 -0
- package/src/components/ui/input-group.tsx +168 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/kbd.tsx +28 -0
- package/src/components/ui/label.tsx +22 -0
- package/src/components/ui/navigation-menu.tsx +168 -0
- package/src/components/ui/page-header.tsx +80 -0
- package/src/components/ui/popover.tsx +87 -0
- package/src/components/ui/scroll-area.tsx +56 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/sheet.tsx +141 -0
- package/src/components/ui/sidebar.tsx +726 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/sonner.tsx +38 -0
- package/src/components/ui/switch.tsx +33 -0
- package/src/components/ui/tabs.tsx +91 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/toggle-group.tsx +83 -0
- package/src/components/ui/toggle.tsx +45 -0
- package/src/components/ui/tooltip.tsx +57 -0
- package/src/hooks/use-copy-to-clipboard.ts +37 -0
- package/src/hooks/use-file-upload.ts +415 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/index.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles.css +1859 -0
- package/src/urls.ts +83 -0
- package/src/vite.d.ts +22 -0
- package/src/vite.js +241 -0
- package/tsconfig.base.json +18 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
Check,
|
|
4
|
+
ThumbsUp,
|
|
5
|
+
Paperclip,
|
|
6
|
+
ListTodo,
|
|
7
|
+
Maximize2,
|
|
8
|
+
MoreHorizontal,
|
|
9
|
+
Plus,
|
|
10
|
+
ArrowRight,
|
|
11
|
+
Link as LinkIcon,
|
|
12
|
+
Pencil,
|
|
13
|
+
} from "lucide-react";
|
|
14
|
+
import {
|
|
15
|
+
Sheet,
|
|
16
|
+
SheetContent,
|
|
17
|
+
} from "@/components/ui/sheet";
|
|
18
|
+
import {
|
|
19
|
+
DropdownMenu,
|
|
20
|
+
DropdownMenuContent,
|
|
21
|
+
DropdownMenuItem,
|
|
22
|
+
DropdownMenuSeparator,
|
|
23
|
+
DropdownMenuTrigger,
|
|
24
|
+
} from "@/components/ui/dropdown-menu";
|
|
25
|
+
import { Button } from "@/components/ui/button";
|
|
26
|
+
import { Separator } from "@/components/ui/separator";
|
|
27
|
+
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
|
28
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
29
|
+
import {
|
|
30
|
+
Avatar,
|
|
31
|
+
AvatarImage,
|
|
32
|
+
AvatarFallback,
|
|
33
|
+
AvatarGroup,
|
|
34
|
+
} from "@/components/ui/avatar";
|
|
35
|
+
import "./section-drawer.css";
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Types
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
export interface SectionDrawerUser {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
avatar?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SectionDrawerActivity {
|
|
48
|
+
id: string;
|
|
49
|
+
user: SectionDrawerUser;
|
|
50
|
+
action: string;
|
|
51
|
+
timestamp: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SectionDrawerProps {
|
|
55
|
+
/** Whether the drawer is open */
|
|
56
|
+
open: boolean;
|
|
57
|
+
/** Callback when open state changes */
|
|
58
|
+
onOpenChange: (open: boolean) => void;
|
|
59
|
+
/** Drawer title */
|
|
60
|
+
title: string;
|
|
61
|
+
/** Whether the item is marked complete */
|
|
62
|
+
isCompleted?: boolean;
|
|
63
|
+
/** Which side the drawer opens from */
|
|
64
|
+
side?: "right" | "left";
|
|
65
|
+
/** Drawer width */
|
|
66
|
+
width?: number | string;
|
|
67
|
+
/** Domain-specific content to render in the main area */
|
|
68
|
+
children: React.ReactNode;
|
|
69
|
+
/** Activity items to display */
|
|
70
|
+
activities?: SectionDrawerActivity[];
|
|
71
|
+
/** Collaborators to display in footer */
|
|
72
|
+
collaborators?: SectionDrawerUser[];
|
|
73
|
+
/** Current user for comment input */
|
|
74
|
+
currentUser?: SectionDrawerUser;
|
|
75
|
+
/** Callback when mark complete is toggled */
|
|
76
|
+
onMarkComplete?: (completed: boolean) => void;
|
|
77
|
+
/** Callback when title is changed (enables title editing when provided) */
|
|
78
|
+
onTitleChange?: (title: string) => void;
|
|
79
|
+
/** Callback when a comment is added */
|
|
80
|
+
onAddComment?: (comment: string) => void;
|
|
81
|
+
/** Callback when attachment button is clicked */
|
|
82
|
+
onAddAttachment?: () => void;
|
|
83
|
+
/** Callback when leave task is clicked */
|
|
84
|
+
onLeaveTask?: () => void;
|
|
85
|
+
/** Callback when copy link is clicked */
|
|
86
|
+
onCopyLink?: () => void;
|
|
87
|
+
/** Callback when fullscreen is clicked */
|
|
88
|
+
onFullScreen?: () => void;
|
|
89
|
+
/** Whether to show the activity section (default: true) */
|
|
90
|
+
showActivity?: boolean;
|
|
91
|
+
/** Whether to show the mark complete button (default: true) */
|
|
92
|
+
showMarkComplete?: boolean;
|
|
93
|
+
/** Callback when delete is clicked */
|
|
94
|
+
onDelete?: () => void;
|
|
95
|
+
/** Callback when duplicate is clicked */
|
|
96
|
+
onDuplicate?: () => void;
|
|
97
|
+
/** Callback when print is clicked */
|
|
98
|
+
onPrint?: () => void;
|
|
99
|
+
/** Callback when add collaborator is clicked */
|
|
100
|
+
onAddCollaborator?: () => void;
|
|
101
|
+
/** Callback when like/thumbs up is clicked */
|
|
102
|
+
onLike?: () => void;
|
|
103
|
+
/** Callback when subtasks header icon is clicked */
|
|
104
|
+
onSubtasksClick?: () => void;
|
|
105
|
+
/** Callback when sort order is toggled */
|
|
106
|
+
onSortToggle?: () => void;
|
|
107
|
+
/** Current sort order for activities */
|
|
108
|
+
sortOrder?: "oldest" | "newest";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// Helpers
|
|
113
|
+
// ============================================================================
|
|
114
|
+
|
|
115
|
+
function getInitials(name: string): string {
|
|
116
|
+
return name
|
|
117
|
+
.split(" ")
|
|
118
|
+
.map((n) => n[0])
|
|
119
|
+
.join("")
|
|
120
|
+
.toUpperCase()
|
|
121
|
+
.slice(0, 2);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============================================================================
|
|
125
|
+
// SectionDrawer Component (Shell)
|
|
126
|
+
// ============================================================================
|
|
127
|
+
|
|
128
|
+
export function SectionDrawer({
|
|
129
|
+
open,
|
|
130
|
+
onOpenChange,
|
|
131
|
+
title,
|
|
132
|
+
isCompleted = false,
|
|
133
|
+
side = "right",
|
|
134
|
+
width = 500,
|
|
135
|
+
children,
|
|
136
|
+
activities = [],
|
|
137
|
+
collaborators = [],
|
|
138
|
+
currentUser,
|
|
139
|
+
onMarkComplete,
|
|
140
|
+
onTitleChange,
|
|
141
|
+
onAddComment,
|
|
142
|
+
onAddAttachment,
|
|
143
|
+
onLeaveTask,
|
|
144
|
+
onCopyLink,
|
|
145
|
+
onFullScreen,
|
|
146
|
+
showActivity = true,
|
|
147
|
+
showMarkComplete = true,
|
|
148
|
+
onDelete,
|
|
149
|
+
onDuplicate,
|
|
150
|
+
onPrint,
|
|
151
|
+
onAddCollaborator,
|
|
152
|
+
onLike,
|
|
153
|
+
onSubtasksClick,
|
|
154
|
+
onSortToggle,
|
|
155
|
+
sortOrder = "oldest",
|
|
156
|
+
}: SectionDrawerProps) {
|
|
157
|
+
// Title editing state
|
|
158
|
+
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
|
159
|
+
const [editedTitle, setEditedTitle] = React.useState(title);
|
|
160
|
+
const titleInputRef = React.useRef<HTMLInputElement>(null);
|
|
161
|
+
|
|
162
|
+
// Comment state
|
|
163
|
+
const [comment, setComment] = React.useState("");
|
|
164
|
+
|
|
165
|
+
React.useEffect(() => {
|
|
166
|
+
setEditedTitle(title);
|
|
167
|
+
}, [title]);
|
|
168
|
+
|
|
169
|
+
React.useEffect(() => {
|
|
170
|
+
if (isEditingTitle && titleInputRef.current) {
|
|
171
|
+
titleInputRef.current.focus();
|
|
172
|
+
titleInputRef.current.select();
|
|
173
|
+
}
|
|
174
|
+
}, [isEditingTitle]);
|
|
175
|
+
|
|
176
|
+
const handleTitleDoubleClick = () => {
|
|
177
|
+
if (onTitleChange) {
|
|
178
|
+
setIsEditingTitle(true);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const handleTitleBlur = () => {
|
|
183
|
+
setIsEditingTitle(false);
|
|
184
|
+
if (editedTitle.trim() && editedTitle !== title) {
|
|
185
|
+
onTitleChange?.(editedTitle.trim());
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const handleTitleKeyDown = (e: React.KeyboardEvent) => {
|
|
190
|
+
if (e.key === "Enter") {
|
|
191
|
+
handleTitleBlur();
|
|
192
|
+
} else if (e.key === "Escape") {
|
|
193
|
+
setIsEditingTitle(false);
|
|
194
|
+
setEditedTitle(title);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const handleCommentSubmit = () => {
|
|
199
|
+
if (comment.trim()) {
|
|
200
|
+
onAddComment?.(comment.trim());
|
|
201
|
+
setComment("");
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const handleCopyLink = () => {
|
|
206
|
+
if (onCopyLink) {
|
|
207
|
+
onCopyLink();
|
|
208
|
+
} else if (typeof navigator !== "undefined" && navigator.clipboard) {
|
|
209
|
+
void navigator.clipboard.writeText(window.location.href);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
215
|
+
<SheetContent
|
|
216
|
+
side={side}
|
|
217
|
+
showCloseButton={false}
|
|
218
|
+
className="section-drawer !max-w-none p-0 flex flex-col gap-0"
|
|
219
|
+
style={{ width, maxWidth: "100vw" }}
|
|
220
|
+
>
|
|
221
|
+
{/* Header */}
|
|
222
|
+
<div className="section-drawer-header">
|
|
223
|
+
<div className="section-drawer-header-actions">
|
|
224
|
+
{showMarkComplete && onMarkComplete && (
|
|
225
|
+
<Button
|
|
226
|
+
variant={isCompleted ? "default" : "outline"}
|
|
227
|
+
size="sm"
|
|
228
|
+
onClick={() => onMarkComplete(!isCompleted)}
|
|
229
|
+
className="section-drawer-complete-btn"
|
|
230
|
+
>
|
|
231
|
+
<Check className="size-4" />
|
|
232
|
+
<span>{isCompleted ? "Completed" : "Mark complete"}</span>
|
|
233
|
+
</Button>
|
|
234
|
+
)}
|
|
235
|
+
{!showMarkComplete && <div />}
|
|
236
|
+
|
|
237
|
+
<div className="section-drawer-header-icons">
|
|
238
|
+
<Button variant="ghost" size="icon-sm" title="Like" onClick={onLike}>
|
|
239
|
+
<ThumbsUp className="size-4" />
|
|
240
|
+
</Button>
|
|
241
|
+
{onAddAttachment && (
|
|
242
|
+
<Button variant="ghost" size="icon-sm" title="Add attachment" onClick={onAddAttachment}>
|
|
243
|
+
<Paperclip className="size-4" />
|
|
244
|
+
</Button>
|
|
245
|
+
)}
|
|
246
|
+
<Button variant="ghost" size="icon-sm" title="Subtasks" onClick={onSubtasksClick}>
|
|
247
|
+
<ListTodo className="size-4" />
|
|
248
|
+
</Button>
|
|
249
|
+
<Button variant="ghost" size="icon-sm" title="Copy link" onClick={handleCopyLink}>
|
|
250
|
+
<LinkIcon className="size-4" />
|
|
251
|
+
</Button>
|
|
252
|
+
{onFullScreen && (
|
|
253
|
+
<Button variant="ghost" size="icon-sm" title="Full screen" onClick={onFullScreen}>
|
|
254
|
+
<Maximize2 className="size-4" />
|
|
255
|
+
</Button>
|
|
256
|
+
)}
|
|
257
|
+
<DropdownMenu>
|
|
258
|
+
<DropdownMenuTrigger asChild>
|
|
259
|
+
<Button variant="ghost" size="icon-sm" title="More actions">
|
|
260
|
+
<MoreHorizontal className="size-4" />
|
|
261
|
+
</Button>
|
|
262
|
+
</DropdownMenuTrigger>
|
|
263
|
+
<DropdownMenuContent align="end">
|
|
264
|
+
{onDuplicate && (
|
|
265
|
+
<DropdownMenuItem onClick={onDuplicate}>
|
|
266
|
+
Duplicate
|
|
267
|
+
</DropdownMenuItem>
|
|
268
|
+
)}
|
|
269
|
+
{onPrint && (
|
|
270
|
+
<DropdownMenuItem onClick={onPrint}>
|
|
271
|
+
Print
|
|
272
|
+
</DropdownMenuItem>
|
|
273
|
+
)}
|
|
274
|
+
<DropdownMenuItem onClick={handleCopyLink}>
|
|
275
|
+
Copy link
|
|
276
|
+
</DropdownMenuItem>
|
|
277
|
+
{onDelete && (
|
|
278
|
+
<>
|
|
279
|
+
<DropdownMenuSeparator />
|
|
280
|
+
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
|
281
|
+
Delete
|
|
282
|
+
</DropdownMenuItem>
|
|
283
|
+
</>
|
|
284
|
+
)}
|
|
285
|
+
</DropdownMenuContent>
|
|
286
|
+
</DropdownMenu>
|
|
287
|
+
<Button
|
|
288
|
+
variant="ghost"
|
|
289
|
+
size="icon-sm"
|
|
290
|
+
onClick={() => onOpenChange(false)}
|
|
291
|
+
title="Close"
|
|
292
|
+
>
|
|
293
|
+
<ArrowRight className="size-4" />
|
|
294
|
+
</Button>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
<Separator />
|
|
300
|
+
|
|
301
|
+
{/* Main Content - Scrollable */}
|
|
302
|
+
<div className="section-drawer-content">
|
|
303
|
+
{/* Title */}
|
|
304
|
+
<div className="section-drawer-title-section">
|
|
305
|
+
{isEditingTitle ? (
|
|
306
|
+
<input
|
|
307
|
+
ref={titleInputRef}
|
|
308
|
+
type="text"
|
|
309
|
+
value={editedTitle}
|
|
310
|
+
onChange={(e) => setEditedTitle(e.target.value)}
|
|
311
|
+
onBlur={handleTitleBlur}
|
|
312
|
+
onKeyDown={handleTitleKeyDown}
|
|
313
|
+
className="section-drawer-title-input"
|
|
314
|
+
/>
|
|
315
|
+
) : (
|
|
316
|
+
<div className="section-drawer-title-wrapper">
|
|
317
|
+
<h2
|
|
318
|
+
className="section-drawer-title"
|
|
319
|
+
onClick={onTitleChange ? handleTitleDoubleClick : undefined}
|
|
320
|
+
title={onTitleChange ? "Click to edit" : undefined}
|
|
321
|
+
style={{ cursor: onTitleChange ? "text" : "default" }}
|
|
322
|
+
>
|
|
323
|
+
{title}
|
|
324
|
+
</h2>
|
|
325
|
+
{onTitleChange && (
|
|
326
|
+
<Button
|
|
327
|
+
variant="ghost"
|
|
328
|
+
size="icon-xs"
|
|
329
|
+
className="section-drawer-title-edit-btn"
|
|
330
|
+
onClick={handleTitleDoubleClick}
|
|
331
|
+
title="Edit title"
|
|
332
|
+
>
|
|
333
|
+
<Pencil className="size-3" />
|
|
334
|
+
</Button>
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
{/* Domain-specific content slot */}
|
|
341
|
+
{children}
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
{/* Activity Section - Fixed at bottom */}
|
|
345
|
+
{showActivity && (
|
|
346
|
+
<div className="section-drawer-activity">
|
|
347
|
+
<Separator />
|
|
348
|
+
|
|
349
|
+
{/* Tabs */}
|
|
350
|
+
<Tabs defaultValue="comments" className="section-drawer-tabs">
|
|
351
|
+
<div className="section-drawer-tabs-header">
|
|
352
|
+
<TabsList variant="line">
|
|
353
|
+
<TabsTrigger value="comments">Comments</TabsTrigger>
|
|
354
|
+
<TabsTrigger value="activity">All activity</TabsTrigger>
|
|
355
|
+
</TabsList>
|
|
356
|
+
<Button variant="ghost" size="xs" className="section-drawer-sort-btn" onClick={onSortToggle}>
|
|
357
|
+
↕ {sortOrder === "oldest" ? "Oldest" : "Newest"}
|
|
358
|
+
</Button>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
<TabsContent value="comments" className="section-drawer-tabs-content">
|
|
362
|
+
<div className="section-drawer-activity-list">
|
|
363
|
+
{activities.map((activity) => (
|
|
364
|
+
<div key={activity.id} className="section-drawer-activity-item">
|
|
365
|
+
<Avatar size="sm">
|
|
366
|
+
{activity.user.avatar ? (
|
|
367
|
+
<AvatarImage src={activity.user.avatar} alt={activity.user.name} />
|
|
368
|
+
) : null}
|
|
369
|
+
<AvatarFallback>{getInitials(activity.user.name)}</AvatarFallback>
|
|
370
|
+
</Avatar>
|
|
371
|
+
<div className="section-drawer-activity-content">
|
|
372
|
+
<span className="font-medium">{activity.user.name}</span>
|
|
373
|
+
<span className="text-muted-foreground"> {activity.action}</span>
|
|
374
|
+
<span className="text-muted-foreground"> · {activity.timestamp}</span>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
))}
|
|
378
|
+
</div>
|
|
379
|
+
</TabsContent>
|
|
380
|
+
|
|
381
|
+
<TabsContent value="activity" className="section-drawer-tabs-content">
|
|
382
|
+
<div className="section-drawer-activity-list">
|
|
383
|
+
{activities.map((activity) => (
|
|
384
|
+
<div key={activity.id} className="section-drawer-activity-item">
|
|
385
|
+
<Avatar size="sm">
|
|
386
|
+
{activity.user.avatar ? (
|
|
387
|
+
<AvatarImage src={activity.user.avatar} alt={activity.user.name} />
|
|
388
|
+
) : null}
|
|
389
|
+
<AvatarFallback>{getInitials(activity.user.name)}</AvatarFallback>
|
|
390
|
+
</Avatar>
|
|
391
|
+
<div className="section-drawer-activity-content">
|
|
392
|
+
<span className="font-medium">{activity.user.name}</span>
|
|
393
|
+
<span className="text-muted-foreground"> {activity.action}</span>
|
|
394
|
+
<span className="text-muted-foreground"> · {activity.timestamp}</span>
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
))}
|
|
398
|
+
</div>
|
|
399
|
+
</TabsContent>
|
|
400
|
+
</Tabs>
|
|
401
|
+
|
|
402
|
+
{/* Comment Input */}
|
|
403
|
+
{onAddComment && (
|
|
404
|
+
<div className="section-drawer-comment-input">
|
|
405
|
+
<Avatar size="sm">
|
|
406
|
+
{currentUser?.avatar ? (
|
|
407
|
+
<AvatarImage src={currentUser.avatar} alt={currentUser.name} />
|
|
408
|
+
) : null}
|
|
409
|
+
<AvatarFallback>
|
|
410
|
+
{currentUser ? getInitials(currentUser.name) : "?"}
|
|
411
|
+
</AvatarFallback>
|
|
412
|
+
</Avatar>
|
|
413
|
+
<Textarea
|
|
414
|
+
placeholder="Add a comment"
|
|
415
|
+
value={comment}
|
|
416
|
+
onChange={(e) => setComment(e.target.value)}
|
|
417
|
+
onKeyDown={(e) => {
|
|
418
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
419
|
+
e.preventDefault();
|
|
420
|
+
handleCommentSubmit();
|
|
421
|
+
}
|
|
422
|
+
}}
|
|
423
|
+
className="section-drawer-comment-textarea"
|
|
424
|
+
/>
|
|
425
|
+
</div>
|
|
426
|
+
)}
|
|
427
|
+
|
|
428
|
+
{/* Footer */}
|
|
429
|
+
<div className="section-drawer-footer">
|
|
430
|
+
<div className="section-drawer-collaborators">
|
|
431
|
+
<span className="section-drawer-collaborators-label">Collaborators</span>
|
|
432
|
+
<AvatarGroup>
|
|
433
|
+
{collaborators.slice(0, 3).map((collab) => (
|
|
434
|
+
<Avatar key={collab.id} size="sm">
|
|
435
|
+
{collab.avatar ? (
|
|
436
|
+
<AvatarImage src={collab.avatar} alt={collab.name} />
|
|
437
|
+
) : null}
|
|
438
|
+
<AvatarFallback>{getInitials(collab.name)}</AvatarFallback>
|
|
439
|
+
</Avatar>
|
|
440
|
+
))}
|
|
441
|
+
</AvatarGroup>
|
|
442
|
+
<Button
|
|
443
|
+
variant="ghost"
|
|
444
|
+
size="icon-xs"
|
|
445
|
+
onClick={onAddCollaborator}
|
|
446
|
+
title="Add collaborator"
|
|
447
|
+
>
|
|
448
|
+
<Plus className="size-3" />
|
|
449
|
+
</Button>
|
|
450
|
+
</div>
|
|
451
|
+
{onLeaveTask && (
|
|
452
|
+
<Button
|
|
453
|
+
variant="ghost"
|
|
454
|
+
size="sm"
|
|
455
|
+
onClick={onLeaveTask}
|
|
456
|
+
className="section-drawer-leave-btn"
|
|
457
|
+
>
|
|
458
|
+
🔔 Leave task
|
|
459
|
+
</Button>
|
|
460
|
+
)}
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
)}
|
|
464
|
+
</SheetContent>
|
|
465
|
+
</Sheet>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Sectioned List Board
|
|
2
|
+
|
|
3
|
+
Data-driven, reusable kanban-style board with draggable sections and cards.
|
|
4
|
+
|
|
5
|
+
## Goal
|
|
6
|
+
|
|
7
|
+
`SectionedListBoard` is **bring your own data only**:
|
|
8
|
+
- Consumers provide `sections`, identity, and mutation handlers.
|
|
9
|
+
- Consumers configure card rendering with `cardConfig`.
|
|
10
|
+
- Consumers do **not** provide card JSX/render functions.
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
import {
|
|
16
|
+
SectionedListBoard,
|
|
17
|
+
type BoardSection,
|
|
18
|
+
type BoardCardConfig,
|
|
19
|
+
type SortState,
|
|
20
|
+
type FilterState,
|
|
21
|
+
} from "~/components/shared/sectioned-list-board";
|
|
22
|
+
|
|
23
|
+
type Deal = {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
assignee: string;
|
|
27
|
+
dueDate: string;
|
|
28
|
+
status: "incomplete" | "complete";
|
|
29
|
+
priority: "High" | "Medium" | "Low";
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const cardConfig: BoardCardConfig<Deal> = {
|
|
33
|
+
titleField: "name",
|
|
34
|
+
badgeField: {
|
|
35
|
+
field: "priority",
|
|
36
|
+
colorMap: { High: "red", Medium: "blue", Low: "green" },
|
|
37
|
+
},
|
|
38
|
+
avatarField: { field: "assignee" },
|
|
39
|
+
dateField: { field: "dueDate", editable: true },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function ExampleBoard({ sections }: { sections: BoardSection<Deal>[] }) {
|
|
43
|
+
const [sort, setSort] = React.useState<SortState>({ field: null, direction: null });
|
|
44
|
+
const [filter, setFilter] = React.useState<FilterState>({ field: null, value: null });
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<SectionedListBoard
|
|
48
|
+
sections={sections}
|
|
49
|
+
getItemId={(item) => item.id}
|
|
50
|
+
cardConfig={cardConfig}
|
|
51
|
+
isItemCompleted={(item) => item.status === "complete"}
|
|
52
|
+
onItemFieldChange={(sectionId, itemId, field, value) => {
|
|
53
|
+
// Persist field updates (e.g. dueDate changes)
|
|
54
|
+
}}
|
|
55
|
+
onItemTitleEdit={(sectionId, itemId, title) => {
|
|
56
|
+
// Persist title updates
|
|
57
|
+
}}
|
|
58
|
+
sort={sort}
|
|
59
|
+
filter={filter}
|
|
60
|
+
onSortChange={setSort}
|
|
61
|
+
onFilterChange={setFilter}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Card Config
|
|
68
|
+
|
|
69
|
+
- `titleField`: Primary card text.
|
|
70
|
+
- `badgeField`: Optional badge from a data field, with optional color mapping.
|
|
71
|
+
- `avatarField`: Optional initials avatar from a data field.
|
|
72
|
+
- `dateField`: Optional date field; if `editable: true`, date picker is enabled.
|
|
73
|
+
|
|
74
|
+
## Notes
|
|
75
|
+
|
|
76
|
+
- Dragging cards is disabled when sort or filter is active.
|
|
77
|
+
- Inline title editing uses `onItemTitleEdit`.
|
|
78
|
+
- Date edits use `onItemFieldChange`.
|