import { DeesElement, type TemplateResult, property, customElement, html, css, cssManager, state, } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; import { DeesContextmenu } from '../../dees-contextmenu/dees-contextmenu.js'; import '../../dees-icon/dees-icon.js'; import type { IActivityEntry, IActivityLogAPI } from '../../interfaces/appconfig.js'; import { demoFunc } from './dees-appui-activitylog.demo.js'; import { themeDefaultStyles } from '../../00theme.js'; @customElement('dees-appui-activitylog') export class DeesAppuiActivitylog extends DeesElement implements IActivityLogAPI { // STATIC public static demo = demoFunc; public static demoGroup = 'App UI'; // INSTANCE PROPERTIES @state() accessor entries: IActivityEntry[] = []; @state() accessor searchQuery: string = ''; @state() accessor filterCriteria: { user?: string; type?: IActivityEntry['type'] } = {}; // RxJS Subject for reactive updates public entries$ = new domtools.plugins.smartrx.rxjs.Subject(); // STYLES public static styles = [ themeDefaultStyles, cssManager.defaultStyles, css` :host { /* CSS Variables aligned with secondary menu */ --activitylog-bg: ${cssManager.bdTheme('#fafafa', '#0a0a0a')}; --activitylog-fg: ${cssManager.bdTheme('#525252', '#a3a3a3')}; --activitylog-fg-muted: ${cssManager.bdTheme('#737373', '#737373')}; --activitylog-fg-active: ${cssManager.bdTheme('#0a0a0a', '#fafafa')}; --activitylog-border: ${cssManager.bdTheme('#e5e5e5', '#1a1a1a')}; --activitylog-hover: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.06)')}; --activitylog-accent: ${cssManager.bdTheme('#78716c', '#b5a99a')}; color: var(--activitylog-fg); position: relative; display: block; width: 100%; height: 100%; background: var(--activitylog-bg); font-family: 'Geist Sans', -apple-system, BlinkMacSystemFont, sans-serif; border-left: 1px solid var(--activitylog-border); cursor: default; overflow: hidden; } .maincontainer { position: absolute; top: 0px; left: 0px; height: 100%; width: 280px; } /* Header with streaming indicator */ .topbar { position: absolute; top: 0px; height: 48px; width: 100%; padding: 0px 12px; background: var(--activitylog-bg); border-bottom: 1px solid var(--activitylog-border); display: flex; align-items: center; justify-content: space-between; box-sizing: border-box; } .topbar .heading { font-weight: 600; font-size: 14px; color: var(--activitylog-fg-active); } .live-indicator { display: flex; align-items: center; gap: 6px; font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; color: var(--activitylog-fg-muted); } .live-indicator .dot { width: 6px; height: 6px; background: ${cssManager.bdTheme('#22c55e', '#22c55e')}; border-radius: 50%; animation: pulse 2s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 0.5; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1.1); } } /* Activity container */ .activityContainer { position: absolute; top: 48px; bottom: 48px; width: 100%; padding: 8px 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: ${cssManager.bdTheme('#d4d4d4', '#333333')} transparent; } .activityContainer::-webkit-scrollbar { width: 6px; } .activityContainer::-webkit-scrollbar-track { background: transparent; } .activityContainer::-webkit-scrollbar-thumb { background: ${cssManager.bdTheme('#d4d4d4', '#333333')}; border-radius: 3px; } .activityContainer::-webkit-scrollbar-thumb:hover { background: ${cssManager.bdTheme('#a3a3a3', '#525252')}; } .empty-state { font-size: 13px; text-align: center; padding: 40px 16px; color: var(--activitylog-fg-muted); } /* Date separator - warm taupe styling */ .date-separator { padding: 12px 12px 6px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--activitylog-accent); position: sticky; top: 0; z-index: 1; background: var(--activitylog-bg); } /* Activity entry - modern stacked layout */ .activityentry { font-size: 12px; padding: 8px 12px; margin: 2px 4px; border-radius: 6px; transition: background 0.15s ease; display: flex; align-items: flex-start; gap: 10px; line-height: 1.4; animation: fadeIn 0.2s ease-out; } @keyframes fadeIn { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: translateY(0); } } .activityentry:hover { background: var(--activitylog-hover); } .activity-icon { width: 28px; height: 28px; border-radius: 6px; background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.06)')}; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 13px; color: var(--activitylog-fg-muted); margin-top: 1px; } .activity-icon.login { background: ${cssManager.bdTheme('rgba(34, 197, 94, 0.08)', 'rgba(34, 197, 94, 0.12)')}; color: ${cssManager.bdTheme('#16a34a', '#4ade80')}; } .activity-icon.logout { background: ${cssManager.bdTheme('rgba(239, 68, 68, 0.08)', 'rgba(239, 68, 68, 0.12)')}; color: ${cssManager.bdTheme('#dc2626', '#f87171')}; } .activity-icon.view { background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.08)', 'rgba(59, 130, 246, 0.12)')}; color: ${cssManager.bdTheme('#2563eb', '#60a5fa')}; } .activity-icon.create { background: ${cssManager.bdTheme('rgba(168, 85, 247, 0.08)', 'rgba(168, 85, 247, 0.12)')}; color: ${cssManager.bdTheme('#9333ea', '#c084fc')}; } .activity-icon.update { background: ${cssManager.bdTheme('rgba(251, 146, 60, 0.08)', 'rgba(251, 146, 60, 0.12)')}; color: ${cssManager.bdTheme('#ea580c', '#fb923c')}; } .activity-icon.delete { background: ${cssManager.bdTheme('rgba(239, 68, 68, 0.08)', 'rgba(239, 68, 68, 0.12)')}; color: ${cssManager.bdTheme('#dc2626', '#f87171')}; } .activity-icon.custom { background: ${cssManager.bdTheme('rgba(100, 116, 139, 0.08)', 'rgba(100, 116, 139, 0.12)')}; color: ${cssManager.bdTheme('#475569', '#94a3b8')}; } .activity-content { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } .activity-header { display: flex; align-items: center; gap: 6px; } .activity-user { font-weight: 600; font-size: 12px; color: var(--activitylog-fg-active); } .activity-separator { color: var(--activitylog-fg-muted); font-size: 10px; } .timestamp { color: var(--activitylog-fg-muted); font-weight: 400; font-size: 11px; font-variant-numeric: tabular-nums; font-family: 'Geist Mono', monospace; } .activity-message { color: var(--activitylog-fg); font-size: 12px; line-height: 1.5; word-break: break-word; } /* Search box - refined styling */ .searchbox { position: absolute; bottom: 0px; width: 100%; height: 48px; background: var(--activitylog-bg); border-top: 1px solid var(--activitylog-border); padding: 8px 12px; box-sizing: border-box; } .search-wrapper { position: relative; width: 100%; height: 32px; } .search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--activitylog-fg-muted); font-size: 13px; pointer-events: none; transition: color 0.15s ease; } .searchbox input { color: var(--activitylog-fg-active); background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(255, 255, 255, 0.04)')}; width: 100%; height: 100%; border: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.08)')}; border-radius: 6px; padding: 0 12px 0 34px; font-family: 'Geist Sans', sans-serif; font-size: 12px; transition: all 0.15s ease; } .searchbox input::placeholder { color: var(--activitylog-fg-muted); } .searchbox input:focus { outline: none; border-color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.15)', 'rgba(255, 255, 255, 0.15)')}; background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.02)', 'rgba(255, 255, 255, 0.06)')}; } .search-wrapper:has(input:focus) .search-icon { color: var(--activitylog-fg); } `, ]; // RENDER public render(): TemplateResult { const filteredEntries = this.getFilteredEntries(); const groupedEntries = this.groupEntriesByDate(filteredEntries); return html` ${domtools.elementBasic.styles}
Activity Log
${filteredEntries.length > 0 ? html`
Live
` : ''}
${filteredEntries.length === 0 ? html`
No activity entries
` : groupedEntries.map( (group) => html`
${group.label}
${group.entries.map((entry) => this.renderActivityEntry(entry))} ` )}
`; } private renderActivityEntry(entry: IActivityEntry): TemplateResult { const timestamp = entry.timestamp || new Date(); const timeStr = this.formatTime(timestamp); const iconName = entry.iconName || this.getIconForType(entry.type); return html`
this.handleContextMenu(e, entry)} >
${entry.user} ยท ${timeStr}
${entry.message}
`; } // API METHODS public add(entry: IActivityEntry): void { const newEntry: IActivityEntry = { ...entry, id: entry.id || this.generateId(), timestamp: entry.timestamp || new Date(), }; this.entries = [newEntry, ...this.entries]; this.entries$.next(this.entries); } public addMany(entries: IActivityEntry[]): void { const newEntries = entries.map((entry) => ({ ...entry, id: entry.id || this.generateId(), timestamp: entry.timestamp || new Date(), })); this.entries = [...newEntries.reverse(), ...this.entries]; this.entries$.next(this.entries); } public clear(): void { this.entries = []; this.entries$.next(this.entries); } public getEntries(): IActivityEntry[] { return [...this.entries]; } public filter(criteria: { user?: string; type?: IActivityEntry['type'] }): IActivityEntry[] { return this.entries.filter((entry) => { if (criteria.user && entry.user !== criteria.user) return false; if (criteria.type && entry.type !== criteria.type) return false; return true; }); } public search(query: string): IActivityEntry[] { const lowerQuery = query.toLowerCase(); return this.entries.filter( (entry) => entry.message.toLowerCase().includes(lowerQuery) || entry.user.toLowerCase().includes(lowerQuery) ); } // PRIVATE HELPERS private generateId(): string { return `activity-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } private getFilteredEntries(): IActivityEntry[] { let result = this.entries; if (this.searchQuery) { const lowerQuery = this.searchQuery.toLowerCase(); result = result.filter( (entry) => entry.message.toLowerCase().includes(lowerQuery) || entry.user.toLowerCase().includes(lowerQuery) ); } if (this.filterCriteria.user || this.filterCriteria.type) { result = result.filter((entry) => { if (this.filterCriteria.user && entry.user !== this.filterCriteria.user) return false; if (this.filterCriteria.type && entry.type !== this.filterCriteria.type) return false; return true; }); } return result; } private groupEntriesByDate( entries: IActivityEntry[] ): Array<{ label: string; entries: IActivityEntry[] }> { const groups: Map = new Map(); const today = new Date(); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); for (const entry of entries) { const date = entry.timestamp || new Date(); let label: string; if (this.isSameDay(date, today)) { label = 'Today'; } else if (this.isSameDay(date, yesterday)) { label = 'Yesterday'; } else { label = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: date.getFullYear() !== today.getFullYear() ? 'numeric' : undefined, }); } if (!groups.has(label)) { groups.set(label, []); } groups.get(label)!.push(entry); } return Array.from(groups.entries()).map(([label, entries]) => ({ label, entries, })); } private isSameDay(date1: Date, date2: Date): boolean { return ( date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate() ); } private formatTime(date: Date): string { return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, }); } private getIconForType(type: IActivityEntry['type']): string { const icons: Record = { login: 'lucide:logIn', logout: 'lucide:logOut', view: 'lucide:eye', create: 'lucide:plus', update: 'lucide:edit', delete: 'lucide:trash2', custom: 'lucide:activity', }; return icons[type] || icons.custom; } private handleSearchInput(e: InputEvent): void { const target = e.target as HTMLInputElement; this.searchQuery = target.value; } private handleContextMenu(e: MouseEvent, entry: IActivityEntry): void { e.preventDefault(); DeesContextmenu.openContextMenuWithOptions(e, [ { name: 'Copy activity', iconName: 'lucide:copy', action: async () => { await navigator.clipboard.writeText(`${entry.user} ${entry.message}`); }, }, { name: 'Filter by user', iconName: 'lucide:user', action: async () => { this.filterCriteria = { user: entry.user }; }, }, { name: 'Filter by type', iconName: 'lucide:filter', action: async () => { this.filterCriteria = { type: entry.type }; }, }, { name: 'Clear filters', iconName: 'lucide:x', action: async () => { this.filterCriteria = {}; this.searchQuery = ''; }, }, ]); } }