588 lines
17 KiB
TypeScript
588 lines
17 KiB
TypeScript
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;
|
|
|
|
// 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<IActivityEntry[]>();
|
|
|
|
// 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%;
|
|
max-width: 320px;
|
|
height: 100%;
|
|
background: var(--activitylog-bg);
|
|
font-family: 'Geist Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
border-left: 1px solid var(--activitylog-border);
|
|
cursor: default;
|
|
}
|
|
|
|
.maincontainer {
|
|
position: absolute;
|
|
top: 0px;
|
|
left: 0px;
|
|
height: 100%;
|
|
width: 100%;
|
|
}
|
|
|
|
/* 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;
|
|
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}
|
|
<style></style>
|
|
<div class="maincontainer">
|
|
<div class="topbar">
|
|
<div class="heading">Activity Log</div>
|
|
${filteredEntries.length > 0
|
|
? html`<div class="live-indicator"><span class="dot"></span>Live</div>`
|
|
: ''}
|
|
</div>
|
|
<div class="activityContainer">
|
|
${filteredEntries.length === 0
|
|
? html`<div class="empty-state">No activity entries</div>`
|
|
: groupedEntries.map(
|
|
(group) => html`
|
|
<div class="date-separator">${group.label}</div>
|
|
${group.entries.map((entry) => this.renderActivityEntry(entry))}
|
|
`
|
|
)}
|
|
</div>
|
|
<div class="searchbox">
|
|
<div class="search-wrapper">
|
|
<dees-icon class="search-icon" .icon=${'lucide:search'}></dees-icon>
|
|
<input
|
|
type="text"
|
|
placeholder="Search activities, users..."
|
|
.value=${this.searchQuery}
|
|
@input=${this.handleSearchInput}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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`
|
|
<div
|
|
class="activityentry"
|
|
@contextmenu=${(e: MouseEvent) => this.handleContextMenu(e, entry)}
|
|
>
|
|
<div class="activity-icon ${entry.type}">
|
|
<dees-icon .icon=${iconName}></dees-icon>
|
|
</div>
|
|
<div class="activity-content">
|
|
<div class="activity-header">
|
|
<span class="activity-user">${entry.user}</span>
|
|
<span class="activity-separator">·</span>
|
|
<span class="timestamp">${timeStr}</span>
|
|
</div>
|
|
<div class="activity-message">${entry.message}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// 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<string, IActivityEntry[]> = 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<IActivityEntry['type'], string> = {
|
|
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 = '';
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
}
|