Skip to content

Commit f6473eb

Browse files
Copilothotlong
andcommitted
feat: implement L2 features - presence avatars, kanban swimlanes, shared view password, undo toast, comment sorting & reactions
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 4017019 commit f6473eb

7 files changed

Lines changed: 337 additions & 36 deletions

File tree

apps/console/src/App.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback, lazy, Suspense, useMemo, type ReactNo
33
import { ObjectForm } from '@object-ui/plugin-form';
44
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
55
import { toast } from 'sonner';
6-
import { SchemaRendererProvider, useActionRunner } from '@object-ui/react';
6+
import { SchemaRendererProvider, useActionRunner, useGlobalUndo } from '@object-ui/react';
77
import type { ConnectionState } from './dataSource';
88
import { AuthGuard, useAuth, PreviewBanner } from '@object-ui/auth';
99
import { MetadataProvider, useMetadata } from './context/MetadataProvider';
@@ -95,6 +95,23 @@ export function AppContent() {
9595
// ActionRunner for CRUD dialog callbacks (Phase 2.9)
9696
const { execute: executeAction, runner } = useActionRunner();
9797

98+
// Global Undo/Redo with toast notifications (Phase 16 L2)
99+
const { redo } = useGlobalUndo({
100+
dataSource: dataSource ?? undefined,
101+
onUndo: (op) => {
102+
toast.info(`Undo: ${op.description}`, {
103+
duration: 4000,
104+
});
105+
setRefreshKey(k => k + 1);
106+
},
107+
onRedo: (op) => {
108+
toast.info(`Redo: ${op.description}`, {
109+
duration: 3000,
110+
});
111+
setRefreshKey(k => k + 1);
112+
},
113+
});
114+
98115
useEffect(() => {
99116
runner.registerHandler('crud_success', async (action) => {
100117
setIsDialogOpen(false);

apps/console/src/components/AppHeader.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { Search, HelpCircle, ChevronDown } from 'lucide-react';
2828

2929
import { useOffline } from '@object-ui/react';
30+
import { PresenceAvatars, type PresenceUser } from '@object-ui/collaboration';
3031
import { ModeToggle } from './mode-toggle';
3132
import { LocaleSwitcher } from './LocaleSwitcher';
3233
import { ConnectionStatus } from './ConnectionStatus';
@@ -40,10 +41,18 @@ function humanizeSlug(slug: string): string {
4041
.replace(/\b\w/g, (c) => c.toUpperCase());
4142
}
4243

43-
export function AppHeader({ appName, objects, connectionState }: { appName: string, objects: any[], connectionState?: ConnectionState }) {
44+
// Demo presence users for local/mock mode
45+
const MOCK_PRESENCE_USERS: PresenceUser[] = [
46+
{ userId: 'u1', userName: 'Alice Chen', color: '#3498db', status: 'active', lastActivity: new Date().toISOString() },
47+
{ userId: 'u2', userName: 'Bob Smith', color: '#2ecc71', status: 'idle', lastActivity: new Date().toISOString() },
48+
{ userId: 'u3', userName: 'Carol Li', color: '#e74c3c', status: 'active', lastActivity: new Date().toISOString() },
49+
];
50+
51+
export function AppHeader({ appName, objects, connectionState, presenceUsers }: { appName: string, objects: any[], connectionState?: ConnectionState, presenceUsers?: PresenceUser[] }) {
4452
const location = useLocation();
4553
const params = useParams();
4654
const { isOnline } = useOffline();
55+
const activeUsers = presenceUsers ?? MOCK_PRESENCE_USERS;
4756

4857
// Parse the current route to build breadcrumbs
4958
const pathParts = location.pathname.split('/').filter(Boolean);
@@ -177,6 +186,13 @@ export function AppHeader({ appName, objects, connectionState }: { appName: stri
177186

178187
{/* Connection Status */}
179188
{connectionState && <ConnectionStatus state={connectionState} />}
189+
190+
{/* Presence Avatars */}
191+
{activeUsers.length > 0 && (
192+
<div className="hidden md:flex items-center shrink-0" title="Users currently online">
193+
<PresenceAvatars users={activeUsers} size="sm" maxVisible={3} showStatus />
194+
</div>
195+
)}
180196

181197
{/* Search - Desktop — opens ⌘K command palette */}
182198
<button

apps/console/src/components/RecordDetailView.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import { useState, useEffect, useCallback } from 'react';
1010
import { useParams } from 'react-router-dom';
1111
import { DetailView } from '@object-ui/plugin-detail';
1212
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
13-
import { CommentThread, type Comment } from '@object-ui/collaboration';
13+
import { CommentThread, PresenceAvatars, type Comment, type PresenceUser } from '@object-ui/collaboration';
1414
import { useAuth } from '@object-ui/auth';
15-
import { Database, MessageSquare } from 'lucide-react';
15+
import { Database, MessageSquare, Users } from 'lucide-react';
1616
import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector';
1717
import { SkeletonDetail } from './skeletons';
1818

@@ -24,6 +24,12 @@ interface RecordDetailViewProps {
2424

2525
const MOCK_USER = { id: 'current-user', name: 'Demo User' };
2626

27+
// Demo presence users viewing the current record
28+
const MOCK_RECORD_VIEWERS: PresenceUser[] = [
29+
{ userId: 'u1', userName: 'Alice Chen', color: '#3498db', status: 'active', lastActivity: new Date().toISOString() },
30+
{ userId: 'u3', userName: 'Carol Li', color: '#e74c3c', status: 'active', lastActivity: new Date().toISOString() },
31+
];
32+
2733
export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailViewProps) {
2834
const { objectName, recordId } = useParams();
2935
const { showDebug, toggleDebug } = useMetadataInspector();
@@ -58,6 +64,24 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
5864
[],
5965
);
6066

67+
const handleReaction = useCallback(
68+
(commentId: string, emoji: string) => {
69+
setComments(prev => prev.map(c => {
70+
if (c.id !== commentId) return c;
71+
const reactions = { ...(c.reactions || {}) };
72+
const userIds = reactions[emoji] || [];
73+
if (userIds.includes(currentUser.id)) {
74+
reactions[emoji] = userIds.filter(id => id !== currentUser.id);
75+
if (reactions[emoji].length === 0) delete reactions[emoji];
76+
} else {
77+
reactions[emoji] = [...userIds, currentUser.id];
78+
}
79+
return { ...c, reactions };
80+
}));
81+
},
82+
[currentUser.id],
83+
);
84+
6185
useEffect(() => {
6286
// Reset loading on navigation; the actual DetailView handles data fetching
6387
setIsLoading(true);
@@ -108,7 +132,14 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
108132

109133
return (
110134
<div className="h-full bg-background overflow-hidden flex flex-col relative">
111-
<div className="absolute top-2 sm:top-4 right-2 sm:right-4 z-50">
135+
<div className="absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2">
136+
{/* Presence: who else is viewing this record */}
137+
{MOCK_RECORD_VIEWERS.length > 0 && (
138+
<div className="flex items-center gap-1.5" title="Users viewing this record">
139+
<Users className="h-3.5 w-3.5 text-muted-foreground" />
140+
<PresenceAvatars users={MOCK_RECORD_VIEWERS} size="sm" maxVisible={4} showStatus />
141+
</div>
142+
)}
112143
<MetadataToggle open={showDebug} onToggle={toggleDebug} />
113144
</div>
114145

@@ -132,6 +163,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
132163
currentUser={currentUser}
133164
onAddComment={handleAddComment}
134165
onDeleteComment={handleDeleteComment}
166+
onReaction={handleReaction}
135167
/>
136168
</div>
137169
</div>

packages/collaboration/src/CommentThread.tsx

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export interface CommentThreadProps {
3737
onDeleteComment?: (commentId: string) => void;
3838
/** Callback when thread is resolved/reopened */
3939
onResolve?: (resolved: boolean) => void;
40+
/** Callback when a reaction is toggled */
41+
onReaction?: (commentId: string, emoji: string) => void;
4042
/** Whether the thread is resolved */
4143
resolved?: boolean;
4244
/** Additional className */
@@ -192,6 +194,48 @@ const styles = {
192194
cursor: 'pointer',
193195
padding: 0,
194196
},
197+
sortSelect: {
198+
background: 'none',
199+
border: '1px solid #e2e8f0',
200+
borderRadius: '4px',
201+
padding: '2px 6px',
202+
fontSize: '11px',
203+
color: '#64748b',
204+
cursor: 'pointer',
205+
outline: 'none',
206+
},
207+
reactionBar: {
208+
display: 'flex',
209+
gap: '4px',
210+
marginTop: '4px',
211+
flexWrap: 'wrap' as const,
212+
},
213+
reactionBtn: {
214+
background: 'none',
215+
border: '1px solid #e2e8f0',
216+
borderRadius: '12px',
217+
padding: '1px 6px',
218+
fontSize: '12px',
219+
cursor: 'pointer',
220+
display: 'inline-flex',
221+
alignItems: 'center',
222+
gap: '2px',
223+
lineHeight: '1.5',
224+
},
225+
reactionBtnActive: {
226+
backgroundColor: '#eff6ff',
227+
borderColor: '#93c5fd',
228+
},
229+
reactionPicker: {
230+
background: 'none',
231+
border: '1px solid #e2e8f0',
232+
borderRadius: '12px',
233+
padding: '1px 6px',
234+
fontSize: '12px',
235+
cursor: 'pointer',
236+
color: '#94a3b8',
237+
lineHeight: '1.5',
238+
},
195239
inputArea: {
196240
display: 'flex',
197241
gap: '8px',
@@ -281,6 +325,7 @@ export function CommentThread({
281325
onEditComment,
282326
onDeleteComment,
283327
onResolve,
328+
onReaction,
284329
resolved = false,
285330
className,
286331
}: CommentThreadProps): React.ReactElement {
@@ -290,6 +335,7 @@ export function CommentThread({
290335
const [editValue, setEditValue] = useState('');
291336
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
292337
const [mentionIndex, setMentionIndex] = useState(0);
338+
const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('oldest');
293339
const inputRef = useRef<HTMLTextAreaElement>(null);
294340

295341
const filteredMentions = useMemo(() => {
@@ -390,8 +436,14 @@ export function CommentThread({
390436
}, [filteredMentions.length, mentionIndex]);
391437

392438
const rootComments = useMemo(
393-
() => comments.filter(c => !c.parentId),
394-
[comments],
439+
() => {
440+
const roots = comments.filter(c => !c.parentId);
441+
if (sortOrder === 'newest') {
442+
return [...roots].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
443+
}
444+
return roots;
445+
},
446+
[comments, sortOrder],
395447
);
396448
const replies = useMemo(
397449
() => comments.filter(c => c.parentId),
@@ -446,12 +498,39 @@ export function CommentThread({
446498
}, 'Cancel'),
447499
)
448500
: React.createElement('div', { style: styles.content }, renderContent(comment.content)),
501+
// Reactions display
502+
!isEditing && comment.reactions && Object.keys(comment.reactions).length > 0 && React.createElement('div', { style: styles.reactionBar },
503+
Object.entries(comment.reactions).map(([emoji, userIds]) =>
504+
React.createElement('button', {
505+
key: emoji,
506+
style: {
507+
...styles.reactionBtn,
508+
...(userIds.includes(currentUser.id) ? styles.reactionBtnActive : {}),
509+
},
510+
onClick: () => onReaction?.(comment.id, emoji),
511+
title: userIds.length === 1 ? '1 reaction' : `${userIds.length} reactions`,
512+
}, `${emoji} ${userIds.length}`),
513+
),
514+
onReaction && React.createElement('button', {
515+
style: styles.reactionPicker,
516+
onClick: () => onReaction(comment.id, '👍'),
517+
title: 'Add reaction',
518+
}, '+'),
519+
),
449520
// Actions
450521
!isEditing && React.createElement('div', { style: styles.actions },
451522
React.createElement('button', {
452523
style: styles.actionBtn,
453524
onClick: () => setReplyTo(comment.id),
454525
}, 'Reply'),
526+
onReaction && React.createElement('button', {
527+
style: styles.actionBtn,
528+
onClick: () => onReaction(comment.id, '👍'),
529+
}, '👍'),
530+
onReaction && React.createElement('button', {
531+
style: styles.actionBtn,
532+
onClick: () => onReaction(comment.id, '❤️'),
533+
}, '❤️'),
455534
isOwner && onEditComment && React.createElement('button', {
456535
style: styles.actionBtn,
457536
onClick: () => handleEdit(comment.id),
@@ -476,10 +555,21 @@ export function CommentThread({
476555
`${comments.length} comment${comments.length !== 1 ? 's' : ''}`,
477556
resolved ? ' · Resolved' : '',
478557
),
479-
onResolve && React.createElement('button', {
480-
style: styles.resolveBtn,
481-
onClick: () => onResolve(!resolved),
482-
}, resolved ? 'Reopen' : 'Resolve'),
558+
React.createElement('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
559+
React.createElement('select', {
560+
value: sortOrder,
561+
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setSortOrder(e.target.value as 'newest' | 'oldest'),
562+
style: styles.sortSelect,
563+
'aria-label': 'Sort comments',
564+
},
565+
React.createElement('option', { value: 'oldest' }, 'Oldest'),
566+
React.createElement('option', { value: 'newest' }, 'Newest'),
567+
),
568+
onResolve && React.createElement('button', {
569+
style: styles.resolveBtn,
570+
onClick: () => onResolve(!resolved),
571+
}, resolved ? 'Reopen' : 'Resolve'),
572+
),
483573
),
484574
// Comments list
485575
React.createElement('div', { style: styles.commentList },

0 commit comments

Comments
 (0)