/** * @file sdig-collaboration-sidebar.ts * @description Compact collaboration sidebar for the contract editor */ import { DeesElement, property, html, customElement, type TemplateResult, css, cssManager, state, } from '@design.estate/dees-element'; import * as plugins from '../../plugins.js'; declare global { interface HTMLElementTagNameMap { 'sdig-collaboration-sidebar': SdigCollaborationSidebar; } } // Comment interface interface IComment { id: string; userId: string; userName: string; userColor: string; content: string; createdAt: number; updatedAt?: number; anchorPath?: string; anchorText?: string; resolved: boolean; replies: IComment[]; } // Suggestion interface interface ISuggestion { id: string; userId: string; userName: string; userColor: string; originalText: string; suggestedText: string; path: string; status: 'pending' | 'accepted' | 'rejected'; createdAt: number; } // Presence interface interface IPresence { userId: string; userName: string; userColor: string; currentSection: string; cursorPosition?: { path: string; offset: number }; lastActive: number; } @customElement('sdig-collaboration-sidebar') export class SdigCollaborationSidebar extends DeesElement { // ============================================================================ // STATIC // ============================================================================ public static demo = () => html`
`; public static styles = [ cssManager.defaultStyles, css` :host { display: block; height: 100%; overflow: hidden; } .sidebar-container { display: flex; flex-direction: column; height: 100%; } /* Presence bar */ .presence-bar { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: ${cssManager.bdTheme('#f9fafb', '#111111')}; border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')}; } .presence-avatars { display: flex; align-items: center; } .presence-avatar { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; color: white; margin-left: -6px; border: 2px solid ${cssManager.bdTheme('#f9fafb', '#111111')}; cursor: pointer; position: relative; } .presence-avatar:first-child { margin-left: 0; } .presence-avatar .status-dot { position: absolute; bottom: -1px; right: -1px; width: 8px; height: 8px; border-radius: 50%; background: #10b981; border: 2px solid ${cssManager.bdTheme('#f9fafb', '#111111')}; } .presence-avatar .status-dot.away { background: #f59e0b; } .presence-count { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')}; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; font-size: 11px; font-weight: 600; margin-left: -6px; border: 2px solid ${cssManager.bdTheme('#f9fafb', '#111111')}; } .presence-label { font-size: 12px; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; } /* Scrollable content */ .sidebar-content { flex: 1; overflow-y: auto; padding: 12px; } /* Collapsible sections */ .collapsible-section { margin-bottom: 12px; border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')}; border-radius: 8px; overflow: hidden; background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')}; } .section-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: ${cssManager.bdTheme('#f9fafb', '#111111')}; cursor: pointer; user-select: none; transition: background 0.15s ease; } .section-header:hover { background: ${cssManager.bdTheme('#f3f4f6', '#18181b')}; } .section-title { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: ${cssManager.bdTheme('#374151', '#d1d5db')}; } .section-title dees-icon { font-size: 14px; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; } .section-badge { padding: 2px 6px; border-radius: 9999px; font-size: 10px; font-weight: 600; background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')}; color: ${cssManager.bdTheme('#1e40af', '#93c5fd')}; } .section-chevron { font-size: 14px; color: ${cssManager.bdTheme('#9ca3af', '#6b7280')}; transition: transform 0.2s ease; } .section-chevron.expanded { transform: rotate(180deg); } .section-body { padding: 0; max-height: 0; overflow: hidden; transition: max-height 0.2s ease, padding 0.2s ease; } .section-body.expanded { padding: 12px; max-height: 1000px; } /* Compact comment cards */ .comment-card { display: flex; gap: 10px; padding: 10px; background: ${cssManager.bdTheme('#f9fafb', '#111111')}; border-radius: 6px; margin-bottom: 8px; cursor: pointer; transition: background 0.15s ease; } .comment-card:last-child { margin-bottom: 0; } .comment-card:hover { background: ${cssManager.bdTheme('#f3f4f6', '#18181b')}; } .comment-card.resolved { opacity: 0.6; } .comment-avatar { width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 600; color: white; flex-shrink: 0; } .comment-body { flex: 1; min-width: 0; } .comment-meta { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } .comment-author { font-size: 12px; font-weight: 600; color: ${cssManager.bdTheme('#374151', '#d1d5db')}; } .comment-time { font-size: 10px; color: ${cssManager.bdTheme('#9ca3af', '#6b7280')}; } .comment-preview { font-size: 12px; line-height: 1.4; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .comment-replies { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')}; margin-top: 4px; } .comment-replies dees-icon { font-size: 12px; } /* Suggestion cards */ .suggestion-card { padding: 10px; background: ${cssManager.bdTheme('#f9fafb', '#111111')}; border-radius: 6px; margin-bottom: 8px; cursor: pointer; transition: background 0.15s ease; } .suggestion-card:last-child { margin-bottom: 0; } .suggestion-card:hover { background: ${cssManager.bdTheme('#f3f4f6', '#18181b')}; } .suggestion-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } .suggestion-user { display: flex; align-items: center; gap: 6px; } .suggestion-status { display: flex; align-items: center; gap: 4px; padding: 2px 6px; border-radius: 9999px; font-size: 10px; font-weight: 500; } .suggestion-status dees-icon { font-size: 10px; } .suggestion-status.pending { background: ${cssManager.bdTheme('#fef3c7', '#422006')}; color: ${cssManager.bdTheme('#92400e', '#fcd34d')}; } .suggestion-status.accepted { background: ${cssManager.bdTheme('#d1fae5', '#064e3b')}; color: ${cssManager.bdTheme('#065f46', '#6ee7b7')}; } .suggestion-status.rejected { background: ${cssManager.bdTheme('#fee2e2', '#450a0a')}; color: ${cssManager.bdTheme('#991b1b', '#fca5a5')}; } .suggestion-diff { font-family: 'Roboto Mono', monospace; font-size: 11px; line-height: 1.4; } .diff-removed { background: ${cssManager.bdTheme('#fee2e2', '#450a0a')}; color: ${cssManager.bdTheme('#991b1b', '#fca5a5')}; text-decoration: line-through; padding: 1px 3px; border-radius: 2px; } .diff-added { background: ${cssManager.bdTheme('#d1fae5', '#064e3b')}; color: ${cssManager.bdTheme('#065f46', '#6ee7b7')}; padding: 1px 3px; border-radius: 2px; } /* Quick add comment */ .quick-add { padding: 12px; border-top: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')}; background: ${cssManager.bdTheme('#f9fafb', '#111111')}; } .quick-add-input { width: 100%; padding: 10px 12px; font-size: 13px; color: ${cssManager.bdTheme('#111111', '#fafafa')}; background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')}; border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')}; border-radius: 6px; outline: none; resize: none; min-height: 60px; font-family: inherit; box-sizing: border-box; } .quick-add-input:focus { border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')}; box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(96, 165, 250, 0.1)')}; } .quick-add-input::placeholder { color: ${cssManager.bdTheme('#9ca3af', '#6b7280')}; } .quick-add-actions { display: flex; justify-content: flex-end; margin-top: 8px; } .btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; font-size: 12px; font-weight: 500; border-radius: 6px; border: none; cursor: pointer; transition: all 0.15s ease; } .btn-primary { background: ${cssManager.bdTheme('#111111', '#fafafa')}; color: ${cssManager.bdTheme('#ffffff', '#09090b')}; } .btn-primary:hover { background: ${cssManager.bdTheme('#333333', '#e5e5e5')}; } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } /* Empty state */ .empty-state { display: flex; flex-direction: column; align-items: center; padding: 24px 16px; text-align: center; color: ${cssManager.bdTheme('#9ca3af', '#6b7280')}; } .empty-state dees-icon { font-size: 32px; margin-bottom: 8px; opacity: 0.5; } .empty-state p { margin: 0; font-size: 12px; } `, ]; // ============================================================================ // PROPERTIES // ============================================================================ @property({ type: Object }) public accessor contract: plugins.sdInterfaces.IPortableContract | null = null; @property({ type: Boolean }) public accessor readonly: boolean = false; // ============================================================================ // STATE // ============================================================================ @state() private accessor commentsExpanded: boolean = true; @state() private accessor suggestionsExpanded: boolean = true; @state() private accessor newCommentText: string = ''; // Demo presence data @state() private accessor presenceList: IPresence[] = [ { userId: '1', userName: 'Alice Smith', userColor: '#3b82f6', currentSection: 'content', lastActive: Date.now() }, { userId: '2', userName: 'Bob Johnson', userColor: '#10b981', currentSection: 'parties', lastActive: Date.now() - 60000 }, { userId: '3', userName: 'Carol Davis', userColor: '#f59e0b', currentSection: 'terms', lastActive: Date.now() - 300000 }, ]; // Demo comments data @state() private accessor comments: IComment[] = [ { id: '1', userId: '1', userName: 'Alice Smith', userColor: '#3b82f6', content: 'Can we clarify the payment terms in paragraph 3? The current wording seems ambiguous.', createdAt: Date.now() - 3600000, anchorPath: 'paragraphs.2', anchorText: 'Compensation', resolved: false, replies: [ { id: '1-1', userId: '2', userName: 'Bob Johnson', userColor: '#10b981', content: 'Good point. I\'ll update the wording to be more specific.', createdAt: Date.now() - 1800000, resolved: false, replies: [], }, ], }, { id: '2', userId: '3', userName: 'Carol Davis', userColor: '#f59e0b', content: 'The termination clause needs to comply with the latest regulations.', createdAt: Date.now() - 86400000, resolved: true, replies: [], }, { id: '3', userId: '2', userName: 'Bob Johnson', userColor: '#10b981', content: 'Should we add an automatic renewal clause?', createdAt: Date.now() - 7200000, resolved: false, replies: [], }, ]; // Demo suggestions data @state() private accessor suggestions: ISuggestion[] = [ { id: '1', userId: '1', userName: 'Alice Smith', userColor: '#3b82f6', originalText: 'monthly salary', suggestedText: 'monthly gross salary', path: 'paragraphs.2.content', status: 'pending', createdAt: Date.now() - 7200000, }, { id: '2', userId: '3', userName: 'Carol Davis', userColor: '#f59e0b', originalText: '30 days', suggestedText: '60 days', path: 'paragraphs.5.content', status: 'pending', createdAt: Date.now() - 3600000, }, ]; // ============================================================================ // EVENT HANDLERS // ============================================================================ private handleCommentClick(comment: IComment) { this.dispatchEvent( new CustomEvent('comment-click', { detail: { comment }, bubbles: true, composed: true, }) ); } private handleSuggestionClick(suggestion: ISuggestion) { this.dispatchEvent( new CustomEvent('suggestion-click', { detail: { suggestion }, bubbles: true, composed: true, }) ); } private handleAddComment() { if (!this.newCommentText.trim()) return; const newComment: IComment = { id: `comment-${Date.now()}`, userId: 'current-user', userName: 'You', userColor: '#6366f1', content: this.newCommentText, createdAt: Date.now(), resolved: false, replies: [], }; this.comments = [newComment, ...this.comments]; this.newCommentText = ''; this.dispatchEvent( new CustomEvent('add-comment', { detail: { comment: newComment }, bubbles: true, composed: true, }) ); } // ============================================================================ // HELPERS // ============================================================================ private formatTimeAgo(timestamp: number): string { const seconds = Math.floor((Date.now() - timestamp) / 1000); if (seconds < 60) return 'now'; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); return `${days}d`; } private getActivePresence(): IPresence[] { const fiveMinutesAgo = Date.now() - 300000; return this.presenceList.filter((p) => p.lastActive > fiveMinutesAgo); } private getOpenComments(): IComment[] { return this.comments.filter((c) => !c.resolved); } private getPendingSuggestions(): ISuggestion[] { return this.suggestions.filter((s) => s.status === 'pending'); } // ============================================================================ // RENDER // ============================================================================ public render(): TemplateResult { const activePresence = this.getActivePresence(); const openComments = this.getOpenComments(); const pendingSuggestions = this.getPendingSuggestions(); return html` `; } private renderCommentCard(comment: IComment): TemplateResult { return html`
this.handleCommentClick(comment)} >
${comment.userName.charAt(0)}
${comment.userName} ${this.formatTimeAgo(comment.createdAt)}
${comment.content}
${comment.replies.length > 0 ? html`
${comment.replies.length} ${comment.replies.length === 1 ? 'reply' : 'replies'}
` : ''}
`; } private renderSuggestionCard(suggestion: ISuggestion): TemplateResult { return html`
this.handleSuggestionClick(suggestion)}>
${suggestion.userName.charAt(0)}
${suggestion.userName}
${suggestion.status}
${suggestion.originalText} ${suggestion.suggestedText}
`; } }