Initial commit: notification-elements-ui library

Angular 19 component library for notifications & communication:
- ntf-bell: notification bell with badge count and shake animation
- ntf-feed / ntf-feed-item: real-time notification feed with grouping
- ntf-center: full notification center with category filter tabs
- ntf-inbox / ntf-inbox-item: two-column inbox with search and detail
- ntf-comment / ntf-thread: comment threads with replies and reactions
- ntf-mention-input: text input with @mention autocomplete
- ntf-empty-state: empty state placeholder
- ntf-item-def: custom template directive for notification items

Includes signal-based services, SCSS design tokens with dark mode,
utility functions, and full TypeScript type definitions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Giuliano Silvestro
2026-02-13 21:48:43 +10:00
commit 32128ccb91
67 changed files with 7334 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
dist/
node_modules/
.angular/
*.tgz

8
build-for-dev.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
set -e
echo "Building @sda/notification-elements-ui for local development..."
npm run build
cd dist
npm pack
echo "Done! Package is ready in dist/"

12
ng-package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "node_modules/ng-packagr/ng-package.schema.json",
"lib": {
"entryFile": "src/index.ts",
"styleIncludePaths": ["src/styles"]
},
"dest": "dist",
"deleteDestPath": true,
"assets": [
"src/styles/**/*.scss"
]
}

3767
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "@sda/notification-elements-ui",
"version": "0.1.0",
"description": "Angular components for notification center, real-time feeds, inbox, comment threads, and @mentions powered by @sda/base-ui",
"keywords": [
"angular",
"notifications",
"inbox",
"comments",
"mentions",
"feed",
"components",
"ui"
],
"repository": {
"type": "git",
"url": "https://git.sky-ai.com/ui-core-design/notification-elements-ui.git"
},
"license": "MIT",
"sideEffects": false,
"scripts": {
"build": "ng-packagr -p ng-package.json",
"build:dev": "./build-for-dev.sh"
},
"peerDependencies": {
"@angular/common": "^19.0.0",
"@angular/core": "^19.0.0",
"@sda/base-ui": "*"
},
"devDependencies": {
"@angular/common": "^19.1.0",
"@angular/compiler": "^19.1.0",
"@angular/compiler-cli": "^19.1.0",
"@angular/core": "^19.1.0",
"@angular/forms": "^19.2.18",
"ng-packagr": "^19.1.0",
"typescript": "~5.7.2"
}
}

11
src/components/index.ts Normal file
View File

@@ -0,0 +1,11 @@
export * from './ntf-bell';
export * from './ntf-feed';
export * from './ntf-feed-item';
export * from './ntf-center';
export * from './ntf-inbox';
export * from './ntf-inbox-item';
export * from './ntf-comment';
export * from './ntf-thread';
export * from './ntf-mention-input';
export * from './ntf-empty-state';
export * from './ntf-item-def';

View File

@@ -0,0 +1 @@
export { NtfBellComponent } from './ntf-bell.component';

View File

@@ -0,0 +1,17 @@
<button
class="ntf-bell"
[class.ntf-bell--sm]="size() === 'sm'"
[class.ntf-bell--lg]="size() === 'lg'"
[class.ntf-bell--animate]="animate() && hasUnread()"
(click)="onClick()"
[attr.aria-label]="hasUnread() ? 'Notifications (' + count() + ' unread)' : 'Notifications'"
>
<svg class="ntf-bell__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
@if (hasUnread()) {
<span class="ntf-bell__badge">{{ displayCount() }}</span>
}
</button>

View File

@@ -0,0 +1,103 @@
:host {
display: inline-block;
}
.ntf-bell {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--ntf-bell-size);
height: var(--ntf-bell-size);
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--ntf-bell-color);
cursor: pointer;
transition:
background-color var(--ntf-transition, 150ms ease-in-out),
color var(--ntf-transition, 150ms ease-in-out);
&:hover {
background: var(--ntf-bell-hover-bg);
}
&:focus-visible {
outline: 2px solid var(--color-primary-500, #3b82f6);
outline-offset: 2px;
}
// Sizes
&--sm {
--ntf-bell-size: 32px;
.ntf-bell__icon {
width: 16px;
height: 16px;
}
.ntf-bell__badge {
min-width: 16px;
height: 16px;
font-size: 0.5625rem;
top: 2px;
right: 2px;
}
}
&--lg {
--ntf-bell-size: 48px;
.ntf-bell__icon {
width: 24px;
height: 24px;
}
}
// Animation
&--animate {
.ntf-bell__icon {
animation: ntf-bell-shake 0.5s ease-in-out;
}
}
}
.ntf-bell__icon {
width: 20px;
height: 20px;
transition: transform var(--ntf-transition, 150ms ease-in-out);
}
.ntf-bell__badge {
position: absolute;
top: 4px;
right: 4px;
min-width: var(--ntf-badge-size);
height: var(--ntf-badge-size);
padding: 0 5px;
border-radius: 999px;
background: var(--ntf-badge-bg);
color: var(--ntf-badge-color);
font-size: 0.625rem;
font-weight: 600;
line-height: var(--ntf-badge-size);
text-align: center;
pointer-events: none;
animation: ntf-badge-pop 300ms var(--ntf-ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1));
}
@keyframes ntf-bell-shake {
0%, 100% { transform: rotate(0deg); }
15% { transform: rotate(14deg); }
30% { transform: rotate(-14deg); }
45% { transform: rotate(10deg); }
60% { transform: rotate(-8deg); }
75% { transform: rotate(4deg); }
}
@keyframes ntf-badge-pop {
0% { transform: scale(0); }
60% { transform: scale(1.2); }
100% { transform: scale(1); }
}

View File

@@ -0,0 +1,28 @@
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
@Component({
selector: 'ntf-bell',
standalone: true,
templateUrl: './ntf-bell.component.html',
styleUrl: './ntf-bell.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NtfBellComponent {
readonly count = input(0);
readonly animate = input(false);
readonly size = input<'sm' | 'md' | 'lg'>('md');
readonly bellClick = output<void>();
readonly displayCount = computed(() => {
const c = this.count();
if (c > 99) return '99+';
return c > 0 ? String(c) : '';
});
readonly hasUnread = computed(() => this.count() > 0);
onClick(): void {
this.bellClick.emit();
}
}

View File

@@ -0,0 +1 @@
export { NtfCenterComponent } from './ntf-center.component';

View File

@@ -0,0 +1,30 @@
<div class="ntf-center">
<!-- Filter tabs -->
<div class="ntf-center__tabs">
@for (filter of filters; track filter.value) {
<button
class="ntf-center__tab"
[class.ntf-center__tab--active]="activeFilter() === filter.value"
(click)="onFilterClick(filter.value)"
>
{{ filter.label }}
</button>
}
</div>
<!-- Feed -->
<ntf-feed
[notifications]="filteredNotifications()"
[grouped]="true"
[loading]="loading()"
[showHeader]="false"
emptyMessage="No notifications"
emptyDescription="Nothing to see here for this filter."
(notificationClick)="onNotificationClick($event)"
(markRead)="onMarkRead($event)"
(markAllRead)="onMarkAllRead($event)"
(archive)="onArchive($event)"
(actionClick)="onActionClick($event)"
(loadMore)="onLoadMore()"
/>
</div>

View File

@@ -0,0 +1,62 @@
:host {
display: block;
}
.ntf-center {
background: var(--ntf-feed-bg);
border: 1px solid var(--ntf-feed-border);
border-radius: var(--ntf-feed-radius);
box-shadow: var(--ntf-feed-shadow);
overflow: hidden;
}
.ntf-center__tabs {
display: flex;
gap: 0.25rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--ntf-item-border);
overflow-x: auto;
&::-webkit-scrollbar {
display: none;
}
}
.ntf-center__tab {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 999px;
background: transparent;
font-size: var(--font-size-xs, 0.75rem);
font-weight: 500;
color: var(--ntf-item-body-color);
cursor: pointer;
white-space: nowrap;
transition:
background-color var(--ntf-transition, 150ms ease-in-out),
color var(--ntf-transition, 150ms ease-in-out);
&:hover {
background: var(--ntf-item-hover-bg);
color: var(--ntf-item-title-color);
}
&--active {
background: var(--color-primary-500, #3b82f6);
color: #ffffff;
&:hover {
background: var(--color-primary-600, #2563eb);
color: #ffffff;
}
}
}
ntf-feed {
::ng-deep .ntf-feed {
border: none;
border-radius: 0;
box-shadow: none;
width: 100%;
}
}

View File

@@ -0,0 +1,78 @@
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { NtfNotification, NtfCategory } from '../../types/notification.types';
import type {
NtfNotificationClickEvent,
NtfActionClickEvent,
NtfMarkReadEvent,
NtfMarkAllReadEvent,
NtfArchiveEvent,
NtfFilterChangeEvent,
} from '../../types/event.types';
import { NtfFeedComponent } from '../ntf-feed/ntf-feed.component';
@Component({
selector: 'ntf-center',
standalone: true,
imports: [CommonModule, NtfFeedComponent],
templateUrl: './ntf-center.component.html',
styleUrl: './ntf-center.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NtfCenterComponent {
readonly notifications = input<NtfNotification[]>([]);
readonly activeFilter = input<NtfCategory | 'all'>('all');
readonly loading = input(false);
readonly notificationClick = output<NtfNotificationClickEvent>();
readonly markRead = output<NtfMarkReadEvent>();
readonly markAllRead = output<NtfMarkAllReadEvent>();
readonly archive = output<NtfArchiveEvent>();
readonly actionClick = output<NtfActionClickEvent>();
readonly loadMore = output<void>();
readonly filterChange = output<NtfFilterChangeEvent>();
readonly filters: { label: string; value: NtfCategory | 'all' }[] = [
{ label: 'All', value: 'all' },
{ label: 'Mentions', value: 'mention' },
{ label: 'Comments', value: 'comment' },
{ label: 'Assignments', value: 'assignment' },
{ label: 'System', value: 'system' },
{ label: 'Alerts', value: 'alert' },
];
readonly filteredNotifications = computed(() => {
const filter = this.activeFilter();
const items = this.notifications();
if (filter === 'all') return items;
return items.filter((n) => n.category === filter);
});
onFilterClick(filter: NtfCategory | 'all'): void {
this.filterChange.emit({ filter });
}
onNotificationClick(event: NtfNotificationClickEvent): void {
this.notificationClick.emit(event);
}
onMarkRead(event: NtfMarkReadEvent): void {
this.markRead.emit(event);
}
onMarkAllRead(event: NtfMarkAllReadEvent): void {
this.markAllRead.emit(event);
}
onArchive(event: NtfArchiveEvent): void {
this.archive.emit(event);
}
onActionClick(event: NtfActionClickEvent): void {
this.actionClick.emit(event);
}
onLoadMore(): void {
this.loadMore.emit();
}
}

View File

@@ -0,0 +1 @@
export { NtfCommentComponent } from './ntf-comment.component';

View File

@@ -0,0 +1,91 @@
<div class="ntf-comment">
<!-- Avatar -->
<div class="ntf-comment__avatar">
@if (comment().authorAvatar) {
<img [src]="comment().authorAvatar" [alt]="comment().authorName" class="ntf-comment__avatar-img" />
} @else {
<div class="ntf-comment__avatar-placeholder">
{{ comment().authorName.charAt(0).toUpperCase() }}
</div>
}
</div>
<!-- Body -->
<div class="ntf-comment__body">
<!-- Header -->
<div class="ntf-comment__header">
<span class="ntf-comment__author">{{ comment().authorName }}</span>
<span class="ntf-comment__time">{{ timeDisplay() }}</span>
@if (editedDisplay()) {
<span class="ntf-comment__edited">({{ editedDisplay() }})</span>
}
</div>
<!-- Content -->
@if (isEditing()) {
<div class="ntf-comment__edit">
<textarea
class="ntf-comment__edit-input"
[ngModel]="editContent()"
(ngModelChange)="editContent.set($event)"
rows="3"
></textarea>
<div class="ntf-comment__edit-actions">
<button class="ntf-comment__edit-btn ntf-comment__edit-btn--cancel" (click)="cancelEdit()">
Cancel
</button>
<button class="ntf-comment__edit-btn ntf-comment__edit-btn--save" (click)="saveEdit()">
Save
</button>
</div>
</div>
} @else {
<div class="ntf-comment__content" [innerHTML]="comment().content"></div>
}
<!-- Reactions -->
@if (comment().reactions?.length && allowReactions()) {
<div class="ntf-comment__reactions">
@for (reaction of comment().reactions; track reaction.emoji) {
<button
class="ntf-comment__reaction"
[class.ntf-comment__reaction--active]="reaction.reacted"
(click)="onReact(reaction.emoji)"
>
<span>{{ reaction.emoji }}</span>
<span class="ntf-comment__reaction-count">{{ reaction.count }}</span>
</button>
}
</div>
}
<!-- Footer actions -->
@if (!isEditing()) {
<div class="ntf-comment__footer">
<button class="ntf-comment__footer-btn" (click)="onReply()">Reply</button>
@if (allowReactions()) {
<button class="ntf-comment__footer-btn" (click)="onReact('👍')">React</button>
}
@if (isOwner()) {
<div class="ntf-comment__more">
<button class="ntf-comment__footer-btn" (click)="toggleActions()">
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14">
<circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/>
</svg>
</button>
@if (showActions()) {
<div class="ntf-comment__more-menu">
@if (allowEdit()) {
<button class="ntf-comment__more-item" (click)="startEdit()">Edit</button>
}
@if (allowDelete()) {
<button class="ntf-comment__more-item ntf-comment__more-item--danger" (click)="onDelete()">Delete</button>
}
</div>
}
</div>
}
</div>
}
</div>
</div>

View File

@@ -0,0 +1,249 @@
:host {
display: block;
}
.ntf-comment {
display: flex;
gap: 0.75rem;
}
.ntf-comment__avatar-img {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.ntf-comment__avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-primary-50, #eff6ff);
color: var(--color-primary-500, #3b82f6);
font-size: 0.75rem;
font-weight: 600;
flex-shrink: 0;
}
.ntf-comment__body {
flex: 1;
min-width: 0;
background: var(--ntf-comment-bg);
border: 1px solid var(--ntf-comment-border);
border-radius: var(--ntf-comment-radius);
padding: var(--ntf-comment-padding);
}
.ntf-comment__header {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.ntf-comment__author {
font-size: var(--ntf-comment-author-font-size);
font-weight: var(--ntf-comment-author-font-weight);
color: var(--ntf-comment-author-color);
}
.ntf-comment__time {
font-size: var(--font-size-xs, 0.75rem);
color: var(--ntf-item-time-color);
}
.ntf-comment__edited {
font-size: var(--font-size-xs, 0.75rem);
color: var(--ntf-item-time-color);
font-style: italic;
}
.ntf-comment__content {
font-size: var(--ntf-comment-content-font-size);
color: var(--ntf-comment-content-color);
line-height: 1.5;
word-break: break-word;
:host ::ng-deep .ntf-mention {
background: var(--ntf-mention-bg);
color: var(--ntf-mention-color);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-weight: 500;
}
}
// Edit mode
.ntf-comment__edit-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--ntf-feed-border);
border-radius: 0.375rem;
background: var(--ntf-item-bg);
color: var(--ntf-item-title-color);
font-size: var(--font-size-sm, 0.875rem);
font-family: inherit;
line-height: 1.5;
resize: vertical;
&:focus {
outline: none;
border-color: var(--color-primary-500, #3b82f6);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
.ntf-comment__edit-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.ntf-comment__edit-btn {
padding: 0.25rem 0.75rem;
border: 1px solid var(--ntf-feed-border);
border-radius: 0.375rem;
font-size: var(--font-size-xs, 0.75rem);
cursor: pointer;
transition:
background-color var(--ntf-transition, 150ms ease-in-out),
border-color var(--ntf-transition, 150ms ease-in-out);
&--cancel {
background: transparent;
color: var(--ntf-item-body-color);
&:hover {
background: var(--ntf-item-hover-bg);
}
}
&--save {
background: var(--color-primary-500, #3b82f6);
color: #ffffff;
border-color: var(--color-primary-500, #3b82f6);
&:hover {
background: var(--color-primary-600, #2563eb);
}
}
}
// Reactions
.ntf-comment__reactions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
}
.ntf-comment__reaction {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
border: 1px solid var(--ntf-feed-border);
border-radius: 999px;
background: transparent;
font-size: var(--font-size-xs, 0.75rem);
cursor: pointer;
transition:
background-color var(--ntf-transition, 150ms ease-in-out),
border-color var(--ntf-transition, 150ms ease-in-out);
&:hover {
background: var(--ntf-item-hover-bg);
}
&--active {
background: var(--ntf-mention-bg);
border-color: var(--color-primary-500, #3b82f6);
}
}
.ntf-comment__reaction-count {
color: var(--ntf-item-body-color);
}
// Footer
.ntf-comment__footer {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.ntf-comment__footer-btn {
padding: 0;
border: none;
background: transparent;
font-size: var(--font-size-xs, 0.75rem);
color: var(--ntf-item-time-color);
cursor: pointer;
transition: color var(--ntf-transition, 150ms ease-in-out);
&:hover {
color: var(--ntf-item-title-color);
}
svg {
display: block;
}
}
.ntf-comment__more {
position: relative;
margin-left: auto;
}
.ntf-comment__more-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 120px;
background: var(--ntf-mention-popup-bg);
border: 1px solid var(--ntf-feed-border);
border-radius: 0.375rem;
box-shadow: var(--ntf-mention-popup-shadow);
z-index: 10;
overflow: hidden;
animation: ntf-menu-in 150ms var(--ntf-ease-smooth, ease-out);
}
.ntf-comment__more-item {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
text-align: left;
font-size: var(--font-size-sm, 0.875rem);
color: var(--ntf-item-title-color);
cursor: pointer;
transition: background-color var(--ntf-transition, 150ms ease-in-out);
&:hover {
background: var(--ntf-item-hover-bg);
}
&--danger {
color: var(--color-error-500, #ef4444);
}
}
@keyframes ntf-menu-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,93 @@
import { Component, ChangeDetectionStrategy, input, output, signal, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NOTIFICATION_CONFIG } from '../../providers/notification-config.provider';
import type { NtfComment } from '../../types/comment.types';
import type {
NtfCommentEditEvent,
NtfCommentDeleteEvent,
NtfReactionEvent,
NtfReplyEvent,
} from '../../types/event.types';
import { formatRelativeTime, formatTimestamp } from '../../utils/time.utils';
@Component({
selector: 'ntf-comment',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './ntf-comment.component.html',
styleUrl: './ntf-comment.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NtfCommentComponent {
private config = inject(NOTIFICATION_CONFIG);
readonly comment = input.required<NtfComment>();
readonly currentUserId = input('');
readonly allowReactions = input(true);
readonly allowEdit = input(true);
readonly allowDelete = input(true);
readonly reply = output<NtfReplyEvent>();
readonly edit = output<NtfCommentEditEvent>();
readonly delete = output<NtfCommentDeleteEvent>();
readonly react = output<NtfReactionEvent>();
readonly isEditing = signal(false);
readonly editContent = signal('');
readonly showActions = signal(false);
readonly isOwner = computed(() => this.comment().authorId === this.currentUserId());
readonly timeDisplay = computed(() => {
const ts = this.comment().timestamp;
return this.config.relativeTime ? formatRelativeTime(ts) : formatTimestamp(ts, this.config.locale);
});
readonly editedDisplay = computed(() => {
const editedAt = this.comment().editedAt;
if (!editedAt) return '';
return `edited ${this.config.relativeTime ? formatRelativeTime(editedAt) : formatTimestamp(editedAt, this.config.locale)}`;
});
onReply(): void {
this.reply.emit({ parentId: this.comment().id });
}
startEdit(): void {
this.editContent.set(this.comment().content);
this.isEditing.set(true);
this.showActions.set(false);
}
cancelEdit(): void {
this.isEditing.set(false);
}
saveEdit(): void {
const newContent = this.editContent().trim();
if (newContent && newContent !== this.comment().content) {
this.edit.emit({
comment: this.comment(),
newContent,
});
}
this.isEditing.set(false);
}
onDelete(): void {
this.delete.emit({ commentId: this.comment().id });
this.showActions.set(false);
}
onReact(emoji: string): void {
this.react.emit({
commentId: this.comment().id,
emoji,
});
}
toggleActions(): void {
this.showActions.update((v) => !v);
}
}

View File

@@ -0,0 +1 @@
export { NtfEmptyStateComponent } from './ntf-empty-state.component';

View File

@@ -0,0 +1,82 @@
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
@Component({
selector: 'ntf-empty-state',
standalone: true,
template: `
<div class="ntf-empty-state">
<div class="ntf-empty-state__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
@switch (icon()) {
@case ('bell') {
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
}
@case ('inbox') {
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/>
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>
}
@case ('comment') {
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
}
@default {
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
}
}
</svg>
</div>
<h3 class="ntf-empty-state__title">{{ title() }}</h3>
@if (description()) {
<p class="ntf-empty-state__description">{{ description() }}</p>
}
</div>
`,
styles: [`
:host {
display: block;
}
.ntf-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1.5rem;
text-align: center;
}
.ntf-empty-state__icon {
width: var(--ntf-empty-icon-size, 48px);
height: var(--ntf-empty-icon-size, 48px);
color: var(--ntf-empty-color, #9ca3af);
margin-bottom: 1rem;
opacity: 0.6;
svg {
width: 100%;
height: 100%;
}
}
.ntf-empty-state__title {
font-size: var(--font-size-sm, 0.875rem);
font-weight: 500;
color: var(--ntf-item-title-color, #111827);
margin-bottom: 0.25rem;
}
.ntf-empty-state__description {
font-size: var(--font-size-xs, 0.75rem);
color: var(--ntf-empty-color, #9ca3af);
margin: 0;
max-width: 240px;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NtfEmptyStateComponent {
readonly icon = input<string>('bell');
readonly title = input<string>('No notifications');
readonly description = input<string>('');
}

View File

@@ -0,0 +1 @@
export { NtfFeedItemComponent } from './ntf-feed-item.component';

View File

@@ -0,0 +1,103 @@
@if (customTemplate()) {
<div class="ntf-feed-item" [class.ntf-feed-item--unread]="isUnread()" (click)="onClick()">
<ng-container *ngTemplateOutlet="customTemplate()!; context: templateContext()" />
</div>
} @else {
<div
class="ntf-feed-item"
[class.ntf-feed-item--unread]="isUnread()"
(click)="onClick()"
role="article"
[attr.aria-label]="notification().title"
>
<!-- Unread indicator -->
@if (isUnread()) {
<div class="ntf-feed-item__unread-dot"></div>
}
<!-- Avatar / Icon -->
<div class="ntf-feed-item__avatar">
@if (notification().avatar || notification().sender?.avatar) {
<img
[src]="notification().avatar || notification().sender?.avatar"
[alt]="notification().sender?.name || 'Notification'"
class="ntf-feed-item__avatar-img"
/>
} @else {
<div class="ntf-feed-item__avatar-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@switch (getCategoryIcon()) {
@case ('at-sign') {
<circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0V12a10 10 0 1 0-3.92 7.94"/>
}
@case ('message') {
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
}
@case ('user-plus') {
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/>
}
@case ('refresh') {
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
}
@case ('alert-triangle') {
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
}
@case ('mail') {
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>
}
@case ('settings') {
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
}
@default {
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
}
}
</svg>
</div>
}
</div>
<!-- Content -->
<div class="ntf-feed-item__content">
<div class="ntf-feed-item__header">
<span class="ntf-feed-item__title">{{ notification().title }}</span>
<span class="ntf-feed-item__time">{{ timeDisplay() }}</span>
</div>
@if (notification().body) {
<p class="ntf-feed-item__body">{{ notification().body }}</p>
}
@if (notification().actions?.length) {
<div class="ntf-feed-item__actions">
@for (action of notification().actions; track action.id) {
<button
class="ntf-feed-item__action"
[class.ntf-feed-item__action--primary]="action.variant === 'primary'"
[class.ntf-feed-item__action--danger]="action.variant === 'danger'"
(click)="onActionClick($event, action)"
>
{{ action.label }}
</button>
}
</div>
}
</div>
<!-- Quick actions (hover) -->
<div class="ntf-feed-item__quick-actions">
@if (isUnread()) {
<button class="ntf-feed-item__quick-btn" (click)="onMarkRead($event)" title="Mark as read">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
}
<button class="ntf-feed-item__quick-btn" (click)="onArchive($event)" title="Archive">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/>
</svg>
</button>
</div>
</div>
}

View File

@@ -0,0 +1,189 @@
:host {
display: block;
}
.ntf-feed-item {
position: relative;
display: flex;
gap: var(--ntf-item-gap);
padding: var(--ntf-item-padding);
background: var(--ntf-item-bg);
border-bottom: 1px solid var(--ntf-item-border);
cursor: pointer;
transition:
background-color var(--ntf-transition, 150ms ease-in-out),
border-color var(--ntf-transition, 150ms ease-in-out);
&:hover {
background: var(--ntf-item-hover-bg);
.ntf-feed-item__quick-actions {
opacity: 1;
pointer-events: auto;
}
}
&--unread {
background: var(--ntf-item-unread-bg);
border-left: 3px solid var(--ntf-item-unread-border);
.ntf-feed-item__title {
font-weight: 600;
}
}
}
.ntf-feed-item__unread-dot {
position: absolute;
top: 50%;
left: 6px;
transform: translateY(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ntf-item-unread-border);
}
.ntf-feed-item__avatar {
flex-shrink: 0;
}
.ntf-feed-item__avatar-img {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
.ntf-feed-item__avatar-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--color-primary-50, #eff6ff);
color: var(--color-primary-500, #3b82f6);
svg {
width: 18px;
height: 18px;
}
}
.ntf-feed-item__content {
flex: 1;
min-width: 0;
}
.ntf-feed-item__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.ntf-feed-item__title {
font-size: var(--ntf-item-title-font-size);
font-weight: var(--ntf-item-title-font-weight);
color: var(--ntf-item-title-color);
line-height: 1.4;
}
.ntf-feed-item__time {
font-size: var(--ntf-item-time-font-size);
color: var(--ntf-item-time-color);
white-space: nowrap;
flex-shrink: 0;
}
.ntf-feed-item__body {
margin: 0.25rem 0 0;
font-size: var(--ntf-item-body-font-size);
color: var(--ntf-item-body-color);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ntf-feed-item__actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.ntf-feed-item__action {
padding: 0.25rem 0.75rem;
border: 1px solid var(--ntf-item-border);
border-radius: 0.375rem;
background: transparent;
font-size: var(--font-size-xs, 0.75rem);
color: var(--ntf-item-title-color);
cursor: pointer;
transition:
background-color var(--ntf-transition, 150ms ease-in-out),
border-color var(--ntf-transition, 150ms ease-in-out);
&:hover {
background: var(--ntf-item-hover-bg);
}
&--primary {
background: var(--color-primary-500, #3b82f6);
color: #ffffff;
border-color: var(--color-primary-500, #3b82f6);
&:hover {
background: var(--color-primary-600, #2563eb);
}
}
&--danger {
color: var(--color-error-500, #ef4444);
border-color: var(--color-error-500, #ef4444);
&:hover {
background: rgba(239, 68, 68, 0.1);
}
}
}
.ntf-feed-item__quick-actions {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
gap: 0.25rem;
opacity: 0;
pointer-events: none;
transition: opacity var(--ntf-transition, 150ms ease-in-out);
}
.ntf-feed-item__quick-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--ntf-item-border);
border-radius: 0.375rem;
background: var(--ntf-item-bg);
color: var(--ntf-item-body-color);
cursor: pointer;
transition:
background-color var(--ntf-transition, 150ms ease-in-out),
color var(--ntf-transition, 150ms ease-in-out);
&:hover {
background: var(--ntf-item-hover-bg);
color: var(--ntf-item-title-color);
}
svg {
width: 14px;
height: 14px;
}
}

View File

@@ -0,0 +1,80 @@
import { Component, ChangeDetectionStrategy, TemplateRef, input, output, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NOTIFICATION_CONFIG } from '../../providers/notification-config.provider';
import type { NtfNotification, NtfAction } from '../../types/notification.types';
import type { NtfNotificationClickEvent, NtfActionClickEvent, NtfMarkReadEvent, NtfArchiveEvent } from '../../types/event.types';
import { formatRelativeTime, formatTimestamp } from '../../utils/time.utils';
import type { NtfItemDefContext } from '../ntf-item-def/ntf-item-def.directive';
@Component({
selector: 'ntf-feed-item',
standalone: true,
imports: [CommonModule],
templateUrl: './ntf-feed-item.component.html',
styleUrl: './ntf-feed-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NtfFeedItemComponent {
private config = inject(NOTIFICATION_CONFIG);
readonly notification = input.required<NtfNotification>();
readonly customTemplate = input<TemplateRef<NtfItemDefContext> | null>(null);
readonly index = input(0);
readonly notificationClick = output<NtfNotificationClickEvent>();
readonly markRead = output<NtfMarkReadEvent>();
readonly archive = output<NtfArchiveEvent>();
readonly actionClick = output<NtfActionClickEvent>();
readonly isUnread = computed(() => this.notification().status === 'unread');
readonly timeDisplay = computed(() => {
const ts = this.notification().timestamp;
return this.config.relativeTime ? formatRelativeTime(ts) : formatTimestamp(ts, this.config.locale);
});
readonly templateContext = computed<NtfItemDefContext>(() => ({
$implicit: this.notification(),
notification: this.notification(),
index: this.index(),
isRead: this.notification().status === 'read',
}));
onClick(): void {
this.notificationClick.emit({
notification: this.notification(),
index: this.index(),
});
}
onMarkRead(event: Event): void {
event.stopPropagation();
this.markRead.emit({ notificationId: this.notification().id });
}
onArchive(event: Event): void {
event.stopPropagation();
this.archive.emit({ notificationId: this.notification().id });
}
onActionClick(event: Event, action: NtfAction): void {
event.stopPropagation();
this.actionClick.emit({
notification: this.notification(),
action,
});
}
getCategoryIcon(): string {
switch (this.notification().category) {
case 'mention': return 'at-sign';
case 'comment': return 'message';
case 'assignment': return 'user-plus';
case 'update': return 'refresh';
case 'alert': return 'alert-triangle';
case 'message': return 'mail';
case 'system': return 'settings';
default: return 'bell';
}
}
}

View File

@@ -0,0 +1 @@
export { NtfFeedComponent } from './ntf-feed.component';

View File

@@ -0,0 +1,70 @@
<div class="ntf-feed">
<!-- Header -->
@if (showHeader()) {
<div class="ntf-feed__header">
<div class="ntf-feed__header-left">
<h3 class="ntf-feed__title">{{ title() }}</h3>
@if (unreadCount() > 0) {
<span class="ntf-feed__unread-badge">{{ unreadCount() }}</span>
}
</div>
@if (unreadCount() > 0) {
<button class="ntf-feed__mark-all" (click)="onMarkAllRead()">
Mark all read
</button>
}
</div>
}
<!-- Content -->
<div class="ntf-feed__content">
@if (loading()) {
<div class="ntf-feed__loading">
<div class="ntf-feed__spinner"></div>
</div>
} @else if (!hasNotifications()) {
<ntf-empty-state
[title]="emptyMessage()"
[description]="emptyDescription()"
icon="bell"
/>
} @else {
@if (grouped()) {
@for (group of groupedNotifications(); track group.label) {
<div class="ntf-feed__group">
<div class="ntf-feed__group-label">{{ group.label }}</div>
@for (item of group.items; track item.id; let i = $index) {
<ntf-feed-item
[notification]="item"
[customTemplate]="itemTemplate()"
[index]="i"
(notificationClick)="onNotificationClick($event)"
(markRead)="onMarkRead($event)"
(archive)="onArchive($event)"
(actionClick)="onActionClick($event)"
/>
}
</div>
}
} @else {
@for (item of visibleNotifications(); track item.id; let i = $index) {
<ntf-feed-item
[notification]="item"
[customTemplate]="itemTemplate()"
[index]="i"
(notificationClick)="onNotificationClick($event)"
(markRead)="onMarkRead($event)"
(archive)="onArchive($event)"
(actionClick)="onActionClick($event)"
/>
}
}
@if (hasMore()) {
<button class="ntf-feed__load-more" (click)="onLoadMore()">
Load more
</button>
}
}
</div>
</div>

View File

@@ -0,0 +1,141 @@
:host {
display: block;
}
.ntf-feed {
background: var(--ntf-feed-bg);
border: 1px solid var(--ntf-feed-border);
border-radius: var(--ntf-feed-radius);
box-shadow: var(--ntf-feed-shadow);
width: var(--ntf-feed-width);
overflow: hidden;
}
.ntf-feed__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1rem 0.75rem;
border-bottom: 1px solid var(--ntf-item-border);
}
.ntf-feed__header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ntf-feed__title {
font-size: var(--font-size-sm, 0.875rem);
font-weight: 600;
color: var(--ntf-item-title-color);
margin: 0;
}
.ntf-feed__unread-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: var(--ntf-badge-bg);
color: var(--ntf-badge-color);
font-size: 0.6875rem;
font-weight: 600;
line-height: 1;
}
.ntf-feed__mark-all {
padding: 0.25rem 0.5rem;
border: none;
border-radius: 0.375rem;
background: transparent;
font-size: var(--font-size-xs, 0.75rem);
color: var(--color-primary-500, #3b82f6);
cursor: pointer;
transition: background-color var(--ntf-transition, 150ms ease-in-out);
&:hover {
background: var(--ntf-item-hover-bg);
}
}
.ntf-feed__content {
max-height: var(--ntf-feed-max-height);
overflow-y: auto;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--ntf-item-time-color);
border-radius: 4px;
&:hover {
background: var(--ntf-item-body-color);
}
}
scrollbar-width: thin;
scrollbar-color: var(--ntf-item-time-color) transparent;
}
.ntf-feed__group-label {
padding: 0.5rem 1rem;
font-size: var(--font-size-xs, 0.75rem);
font-weight: 600;
color: var(--ntf-item-time-color);
text-transform: uppercase;
letter-spacing: 0.05em;
background: var(--ntf-item-hover-bg);
border-bottom: 1px solid var(--ntf-item-border);
position: sticky;
top: 0;
z-index: 1;
}
.ntf-feed__loading {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
}
.ntf-feed__spinner {
width: 24px;
height: 24px;
border: 2px solid var(--ntf-item-border);
border-top-color: var(--color-primary-500, #3b82f6);
border-radius: 50%;
animation: ntf-spin 0.6s linear infinite;
}
.ntf-feed__load-more {
display: block;
width: 100%;
padding: 0.75rem;
border: none;
border-top: 1px solid var(--ntf-item-border);
background: transparent;
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-primary-500, #3b82f6);
cursor: pointer;
transition: background-color var(--ntf-transition, 150ms ease-in-out);
&:hover {
background: var(--ntf-item-hover-bg);
}
}
@keyframes ntf-spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,91 @@
import { Component, ChangeDetectionStrategy, input, output, computed, contentChildren } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { NtfNotification } from '../../types/notification.types';
import type {
NtfNotificationClickEvent,
NtfActionClickEvent,
NtfMarkReadEvent,
NtfMarkAllReadEvent,
NtfArchiveEvent,
} from '../../types/event.types';
import { groupByDate } from '../../utils/time.utils';
import { NtfFeedItemComponent } from '../ntf-feed-item/ntf-feed-item.component';
import { NtfEmptyStateComponent } from '../ntf-empty-state/ntf-empty-state.component';
import { NtfItemDefDirective } from '../ntf-item-def/ntf-item-def.directive';
@Component({
selector: 'ntf-feed',
standalone: true,
imports: [CommonModule, NtfFeedItemComponent, NtfEmptyStateComponent],
templateUrl: './ntf-feed.component.html',
styleUrl: './ntf-feed.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NtfFeedComponent {
readonly notifications = input<NtfNotification[]>([]);
readonly grouped = input(false);
readonly maxVisible = input(50);
readonly loading = input(false);
readonly emptyMessage = input('No notifications yet');
readonly emptyDescription = input('When you receive notifications, they will appear here.');
readonly showHeader = input(true);
readonly title = input('Notifications');
readonly notificationClick = output<NtfNotificationClickEvent>();
readonly markRead = output<NtfMarkReadEvent>();
readonly markAllRead = output<NtfMarkAllReadEvent>();
readonly archive = output<NtfArchiveEvent>();
readonly actionClick = output<NtfActionClickEvent>();
readonly loadMore = output<void>();
private itemDefs = contentChildren(NtfItemDefDirective);
protected itemTemplate = computed(() => {
const defs = this.itemDefs();
return defs.length > 0 ? defs[0].templateRef : null;
});
readonly visibleNotifications = computed(() => {
const items = this.notifications().filter((n) => n.status !== 'archived');
return items.slice(0, this.maxVisible());
});
readonly unreadCount = computed(() =>
this.notifications().filter((n) => n.status === 'unread').length,
);
readonly groupedNotifications = computed(() => {
if (!this.grouped()) return [];
return groupByDate(this.visibleNotifications());
});
readonly hasNotifications = computed(() => this.visibleNotifications().length > 0);
readonly hasMore = computed(() =>
this.notifications().filter((n) => n.status !== 'archived').length > this.maxVisible(),
);
onNotificationClick(event: NtfNotificationClickEvent): void {
this.notificationClick.emit(event);
}
onMarkRead(event: NtfMarkReadEvent): void {
this.markRead.emit(event);
}
onMarkAllRead(): void {
this.markAllRead.emit({ count: this.unreadCount() });
}
onArchive(event: NtfArchiveEvent): void {
this.archive.emit(event);
}
onActionClick(event: NtfActionClickEvent): void {
this.actionClick.emit(event);
}
onLoadMore(): void {
this.loadMore.emit();
}
}

View File

@@ -0,0 +1 @@
export { NtfInboxItemComponent } from './ntf-inbox-item.component';

View File

@@ -0,0 +1,42 @@
<div
class="ntf-inbox-item"
[class.ntf-inbox-item--unread]="!message().read"
[class.ntf-inbox-item--selected]="selected()"
(click)="onClick()"
role="article"
[attr.aria-label]="message().subject"
>
<!-- Star -->
<button class="ntf-inbox-item__star" (click)="onStar($event)" [attr.aria-label]="message().starred ? 'Unstar' : 'Star'">
<svg viewBox="0 0 24 24" [attr.fill]="message().starred ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
</button>
<!-- Content -->
<div class="ntf-inbox-item__content">
<div class="ntf-inbox-item__header">
<span class="ntf-inbox-item__from">{{ message().from }}</span>
<span class="ntf-inbox-item__time">{{ timeDisplay() }}</span>
</div>
<div class="ntf-inbox-item__subject">{{ message().subject }}</div>
<div class="ntf-inbox-item__preview">{{ message().preview }}</div>
@if (message().labels?.length) {
<div class="ntf-inbox-item__labels">
@for (label of message().labels; track label.id) {
<span class="ntf-inbox-item__label" [style.background]="label.color + '20'" [style.color]="label.color">
{{ label.name }}
</span>
}
</div>
}
</div>
<!-- Archive -->
<button class="ntf-inbox-item__archive" (click)="onArchive($event)" title="Archive">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/>
</svg>
</button>
</div>

View File

@@ -0,0 +1,156 @@
:host {
display: block;
}
.ntf-inbox-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--ntf-inbox-item-bg);
border-bottom: 1px solid var(--ntf-item-border);
cursor: pointer;
transition:
background-color var(--ntf-transition, 150ms ease-in-out),
border-color var(--ntf-transition, 150ms ease-in-out);
&:hover {
background: var(--ntf-inbox-item-hover-bg);
.ntf-inbox-item__archive {
opacity: 1;
}
}
&--unread {
.ntf-inbox-item__from,
.ntf-inbox-item__subject {
font-weight: var(--ntf-inbox-unread-weight);
}
}
&--selected {
background: var(--ntf-inbox-selected-bg);
}
}
.ntf-inbox-item__star {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
color: var(--ntf-item-time-color);
cursor: pointer;
flex-shrink: 0;
margin-top: 2px;
transition: color var(--ntf-transition, 150ms ease-in-out);
svg {
width: 14px;
height: 14px;
}
&:hover {
color: var(--ntf-inbox-star-color);
}
.ntf-inbox-item--unread &,
[fill="currentColor"] & {
color: var(--ntf-inbox-star-color);
}
}
// Star filled state
.ntf-inbox-item__star svg[fill="currentColor"] {
color: var(--ntf-inbox-star-color);
}
.ntf-inbox-item__content {
flex: 1;
min-width: 0;
}
.ntf-inbox-item__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.ntf-inbox-item__from {
font-size: var(--font-size-sm, 0.875rem);
color: var(--ntf-item-title-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ntf-inbox-item__time {
font-size: var(--font-size-xs, 0.75rem);
color: var(--ntf-item-time-color);
white-space: nowrap;
flex-shrink: 0;
}
.ntf-inbox-item__subject {
font-size: var(--font-size-sm, 0.875rem);
color: var(--ntf-item-title-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 0.125rem;
}
.ntf-inbox-item__preview {
font-size: var(--font-size-xs, 0.75rem);
color: var(--ntf-item-body-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 0.125rem;
}
.ntf-inbox-item__labels {
display: flex;
gap: 0.25rem;
margin-top: 0.375rem;
}
.ntf-inbox-item__label {
display: inline-block;
padding: 0.0625rem 0.375rem;
border-radius: 999px;
font-size: 0.625rem;
font-weight: 500;
}
.ntf-inbox-item__archive {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
color: var(--ntf-item-time-color);
cursor: pointer;
flex-shrink: 0;
opacity: 0;
transition:
opacity var(--ntf-transition, 150ms ease-in-out),
color var(--ntf-transition, 150ms ease-in-out);
svg {
width: 14px;
height: 14px;
}
&:hover {
color: var(--ntf-item-title-color);
}
}

View File

@@ -0,0 +1,47 @@
import { Component, ChangeDetectionStrategy, input, output, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NOTIFICATION_CONFIG } from '../../providers/notification-config.provider';
import type { NtfInboxMessage } from '../../types/inbox.types';
import type { NtfInboxSelectEvent, NtfInboxStarEvent, NtfInboxArchiveEvent } from '../../types/event.types';
import { formatRelativeTime, formatTimestamp } from '../../utils/time.utils';
@Component({
selector: 'ntf-inbox-item',
standalone: true,
imports: [CommonModule],
templateUrl: './ntf-inbox-item.component.html',
styleUrl: './ntf-inbox-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NtfInboxItemComponent {
private config = inject(NOTIFICATION_CONFIG);
readonly message = input.required<NtfInboxMessage>();
readonly selected = input(false);
readonly select = output<NtfInboxSelectEvent>();
readonly star = output<NtfInboxStarEvent>();
readonly archive = output<NtfInboxArchiveEvent>();
readonly timeDisplay = computed(() => {
const ts = this.message().timestamp;
return this.config.relativeTime ? formatRelativeTime(ts) : formatTimestamp(ts, this.config.locale);
});
onClick(): void {
this.select.emit({ message: this.message() });
}
onStar(event: Event): void {
event.stopPropagation();
this.star.emit({
messageId: this.message().id,
starred: !this.message().starred,
});
}
onArchive(event: Event): void {
event.stopPropagation();
this.archive.emit({ messageId: this.message().id });
}
}

View File

@@ -0,0 +1 @@
export { NtfInboxComponent } from './ntf-inbox.component';

View File

@@ -0,0 +1,66 @@
<div class="ntf-inbox">
<!-- Message list -->
<div class="ntf-inbox__list">
<!-- Search -->
<div class="ntf-inbox__search">
<svg class="ntf-inbox__search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
type="text"
class="ntf-inbox__search-input"
placeholder="Search messages..."
[value]="searchTerm()"
(input)="onSearch($event)"
/>
</div>
<!-- Messages -->
<div class="ntf-inbox__messages">
@if (loading()) {
<div class="ntf-inbox__loading">
<div class="ntf-inbox__spinner"></div>
</div>
} @else if (filteredMessages().length === 0) {
<ntf-empty-state
icon="inbox"
title="No messages"
description="Your inbox is empty."
/>
} @else {
@for (msg of filteredMessages(); track msg.id) {
<ntf-inbox-item
[message]="msg"
[selected]="selectedId() === msg.id"
(select)="onMessageSelect($event)"
(star)="onMessageStar($event)"
(archive)="onMessageArchive($event)"
/>
}
}
</div>
</div>
<!-- Detail pane -->
<div class="ntf-inbox__detail">
@if (selectedMessage()) {
<div class="ntf-inbox__detail-header">
<h2 class="ntf-inbox__detail-subject">{{ selectedMessage()!.subject }}</h2>
<div class="ntf-inbox__detail-meta">
<span class="ntf-inbox__detail-from">{{ selectedMessage()!.from }}</span>
</div>
</div>
<div class="ntf-inbox__detail-body">
{{ selectedMessage()!.body || selectedMessage()!.preview }}
</div>
} @else {
<div class="ntf-inbox__detail-empty">
<ntf-empty-state
icon="inbox"
title="Select a message"
description="Choose a message from the list to view its contents."
/>
</div>
}
</div>
</div>

View File

@@ -0,0 +1,141 @@
:host {
display: block;
}
.ntf-inbox {
display: flex;
height: 100%;
min-height: 400px;
background: var(--ntf-feed-bg);
border: 1px solid var(--ntf-feed-border);
border-radius: var(--ntf-feed-radius);
overflow: hidden;
}
// List panel
.ntf-inbox__list {
width: 360px;
min-width: 280px;
border-right: 1px solid var(--ntf-item-border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.ntf-inbox__search {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--ntf-item-border);
}
.ntf-inbox__search-icon {
width: 16px;
height: 16px;
color: var(--ntf-item-time-color);
flex-shrink: 0;
}
.ntf-inbox__search-input {
flex: 1;
border: none;
background: transparent;
font-size: var(--font-size-sm, 0.875rem);
color: var(--ntf-item-title-color);
outline: none;
&::placeholder {
color: var(--ntf-item-time-color);
}
}
.ntf-inbox__messages {
flex: 1;
overflow-y: auto;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--ntf-item-time-color);
border-radius: 4px;
}
scrollbar-width: thin;
scrollbar-color: var(--ntf-item-time-color) transparent;
}
.ntf-inbox__loading {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
}
.ntf-inbox__spinner {
width: 24px;
height: 24px;
border: 2px solid var(--ntf-item-border);
border-top-color: var(--color-primary-500, #3b82f6);
border-radius: 50%;
animation: ntf-inbox-spin 0.6s linear infinite;
}
// Detail panel
.ntf-inbox__detail {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.ntf-inbox__detail-header {
padding: 1.5rem;
border-bottom: 1px solid var(--ntf-item-border);
}
.ntf-inbox__detail-subject {
font-size: var(--font-size-lg, 1.125rem);
font-weight: 600;
color: var(--ntf-item-title-color);
margin: 0 0 0.25rem;
}
.ntf-inbox__detail-meta {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ntf-inbox__detail-from {
font-size: var(--font-size-sm, 0.875rem);
color: var(--ntf-item-body-color);
}
.ntf-inbox__detail-body {
flex: 1;
padding: 1.5rem;
font-size: var(--font-size-sm, 0.875rem);
color: var(--ntf-item-title-color);
line-height: 1.7;
white-space: pre-wrap;
}
.ntf-inbox__detail-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
@keyframes ntf-inbox-spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,65 @@
import { Component, ChangeDetectionStrategy, input, output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import type { NtfInboxMessage } from '../../types/inbox.types';
import type { NtfInboxSelectEvent, NtfInboxStarEvent, NtfInboxArchiveEvent } from '../../types/event.types';
import { NtfInboxItemComponent } from '../ntf-inbox-item/ntf-inbox-item.component';
import { NtfEmptyStateComponent } from '../ntf-empty-state/ntf-empty-state.component';
@Component({
selector: 'ntf-inbox',
standalone: true,
imports: [CommonModule, FormsModule, NtfInboxItemComponent, NtfEmptyStateComponent],
templateUrl: './ntf-inbox.component.html',
styleUrl: './ntf-inbox.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NtfInboxComponent {
readonly messages = input<NtfInboxMessage[]>([]);
readonly selectedId = input<string>('');
readonly loading = input(false);
readonly messageSelect = output<NtfInboxSelectEvent>();
readonly messageStar = output<NtfInboxStarEvent>();
readonly messageArchive = output<NtfInboxArchiveEvent>();
readonly messageDelete = output<{ messageId: string }>();
readonly searchTerm = signal('');
readonly filteredMessages = computed(() => {
const term = this.searchTerm().toLowerCase();
if (!term) return this.messages();
return this.messages().filter(
(m) =>
m.subject.toLowerCase().includes(term) ||
m.from.toLowerCase().includes(term) ||
m.preview.toLowerCase().includes(term),
);
});
readonly selectedMessage = computed(() => {
const id = this.selectedId();
if (!id) return null;
return this.messages().find((m) => m.id === id) ?? null;
});
readonly unreadCount = computed(() =>
this.messages().filter((m) => !m.read).length,
);
onSearch(event: Event): void {
this.searchTerm.set((event.target as HTMLInputElement).value);
}
onMessageSelect(event: NtfInboxSelectEvent): void {
this.messageSelect.emit(event);
}
onMessageStar(event: NtfInboxStarEvent): void {
this.messageStar.emit(event);
}
onMessageArchive(event: NtfInboxArchiveEvent): void {
this.messageArchive.emit(event);
}
}

View File

@@ -0,0 +1 @@
export { NtfItemDefDirective, type NtfItemDefContext } from './ntf-item-def.directive';

View File

@@ -0,0 +1,25 @@
import { Directive, TemplateRef, inject } from '@angular/core';
import type { NtfNotification } from '../../types/notification.types';
/** Template context for custom notification item rendering */
export interface NtfItemDefContext {
$implicit: NtfNotification;
notification: NtfNotification;
index: number;
isRead: boolean;
}
@Directive({
selector: 'ng-template[ntfItemDef]',
standalone: true,
})
export class NtfItemDefDirective {
readonly templateRef = inject(TemplateRef<NtfItemDefContext>);
static ngTemplateContextGuard(
_dir: NtfItemDefDirective,
ctx: unknown,
): ctx is NtfItemDefContext {
return true;
}
}

View File

@@ -0,0 +1 @@
export { NtfMentionInputComponent } from './ntf-mention-input.component';

View File

@@ -0,0 +1,59 @@
<div class="ntf-mention-input">
<div class="ntf-mention-input__wrapper">
@if (multiline()) {
<textarea
#inputEl
class="ntf-mention-input__field ntf-mention-input__field--multiline"
[value]="textValue()"
[placeholder]="placeholder()"
[attr.maxlength]="effectiveMaxLength() || null"
(input)="onInput($event)"
(keydown)="onKeydown($event)"
(blur)="onBlur()"
rows="3"
></textarea>
} @else {
<input
#inputEl
type="text"
class="ntf-mention-input__field"
[value]="textValue()"
[placeholder]="placeholder()"
[attr.maxlength]="effectiveMaxLength() || null"
(input)="onInput($event)"
(keydown)="onKeydown($event)"
(blur)="onBlur()"
/>
}
<!-- Mention popup -->
@if (showPopup() && filteredUsers().length > 0) {
<div class="ntf-mention-input__popup">
@for (user of filteredUsers(); track user.id; let i = $index) {
<button
class="ntf-mention-input__popup-item"
[class.ntf-mention-input__popup-item--active]="i === selectedIndex()"
(mousedown)="selectMention(user)"
>
@if (user.avatar) {
<img class="ntf-mention-input__popup-avatar" [src]="user.avatar" [alt]="user.name" />
} @else {
<div class="ntf-mention-input__popup-avatar-placeholder">
{{ user.name.charAt(0).toUpperCase() }}
</div>
}
<span class="ntf-mention-input__popup-name">{{ user.name }}</span>
</button>
}
</div>
}
</div>
@if (effectiveMaxLength() > 0) {
<div class="ntf-mention-input__footer">
<span class="ntf-mention-input__counter">
{{ textValue().length }} / {{ effectiveMaxLength() }}
</span>
</div>
}
</div>

View File

@@ -0,0 +1,133 @@
:host {
display: block;
}
.ntf-mention-input {
position: relative;
}
.ntf-mention-input__wrapper {
position: relative;
}
.ntf-mention-input__field {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid var(--ntf-feed-border);
border-radius: 0.5rem;
background: var(--ntf-item-bg);
color: var(--ntf-item-title-color);
font-size: var(--font-size-sm, 0.875rem);
font-family: inherit;
line-height: 1.5;
resize: none;
transition:
border-color var(--ntf-transition, 150ms ease-in-out),
box-shadow var(--ntf-transition, 150ms ease-in-out);
&::placeholder {
color: var(--ntf-item-time-color);
}
&:focus {
outline: none;
border-color: var(--color-primary-500, #3b82f6);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
&--multiline {
min-height: 80px;
resize: vertical;
}
}
.ntf-mention-input__popup {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin-bottom: 4px;
background: var(--ntf-mention-popup-bg);
border: 1px solid var(--ntf-feed-border);
border-radius: var(--ntf-mention-popup-radius);
box-shadow: var(--ntf-mention-popup-shadow);
max-height: var(--ntf-mention-popup-max-height);
overflow-y: auto;
z-index: 10;
animation: ntf-popup-in 150ms var(--ntf-ease-smooth, ease-out);
}
.ntf-mention-input__popup-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
transition: background-color var(--ntf-transition, 150ms ease-in-out);
&:hover,
&--active {
background: var(--ntf-item-hover-bg);
}
&:first-child {
border-radius: var(--ntf-mention-popup-radius) var(--ntf-mention-popup-radius) 0 0;
}
&:last-child {
border-radius: 0 0 var(--ntf-mention-popup-radius) var(--ntf-mention-popup-radius);
}
}
.ntf-mention-input__popup-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.ntf-mention-input__popup-avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--color-primary-50, #eff6ff);
color: var(--color-primary-500, #3b82f6);
font-size: 0.6875rem;
font-weight: 600;
flex-shrink: 0;
}
.ntf-mention-input__popup-name {
font-size: var(--font-size-sm, 0.875rem);
color: var(--ntf-item-title-color);
}
.ntf-mention-input__footer {
display: flex;
justify-content: flex-end;
padding-top: 0.25rem;
}
.ntf-mention-input__counter {
font-size: var(--font-size-xs, 0.75rem);
color: var(--ntf-item-time-color);
}
@keyframes ntf-popup-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,140 @@
import {
Component, ChangeDetectionStrategy, input, output, signal, computed,
ElementRef, viewChild, inject,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NOTIFICATION_CONFIG } from '../../providers/notification-config.provider';
import type { NtfMention } from '../../types/notification.types';
import type { NtfMentionSelectEvent, NtfMentionSearchEvent } from '../../types/event.types';
import { insertMention } from '../../utils/mention.utils';
@Component({
selector: 'ntf-mention-input',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './ntf-mention-input.component.html',
styleUrl: './ntf-mention-input.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NtfMentionInputComponent {
private config = inject(NOTIFICATION_CONFIG);
readonly value = input('');
readonly users = input<NtfMention[]>([]);
readonly placeholder = input('Type a message...');
readonly multiline = input(true);
readonly maxLength = input(0);
readonly valueChange = output<string>();
readonly mentionSearch = output<NtfMentionSearchEvent>();
readonly mentionSelect = output<NtfMentionSelectEvent>();
readonly submit = output<string>();
readonly textValue = signal('');
readonly showPopup = signal(false);
readonly mentionQuery = signal('');
readonly selectedIndex = signal(0);
private inputRef = viewChild<ElementRef<HTMLTextAreaElement | HTMLInputElement>>('inputEl');
readonly filteredUsers = computed(() => {
const q = this.mentionQuery().toLowerCase();
if (!q) return this.users();
return this.users().filter((u) => u.name.toLowerCase().includes(q));
});
readonly effectiveMaxLength = computed(() =>
this.maxLength() || this.config.commentMaxLength,
);
onInput(event: Event): void {
const target = event.target as HTMLTextAreaElement | HTMLInputElement;
const val = target.value;
this.textValue.set(val);
this.valueChange.emit(val);
const cursorPos = target.selectionStart ?? val.length;
const textBeforeCursor = val.substring(0, cursorPos);
const triggerIndex = textBeforeCursor.lastIndexOf(this.config.mentionTrigger);
if (triggerIndex !== -1) {
const charBefore = triggerIndex > 0 ? textBeforeCursor[triggerIndex - 1] : ' ';
if (charBefore === ' ' || charBefore === '\n' || triggerIndex === 0) {
const query = textBeforeCursor.substring(triggerIndex + 1);
if (!query.includes(' ')) {
this.mentionQuery.set(query);
this.showPopup.set(true);
this.selectedIndex.set(0);
this.mentionSearch.emit({ query });
return;
}
}
}
this.showPopup.set(false);
}
onKeydown(event: KeyboardEvent): void {
if (this.showPopup()) {
const users = this.filteredUsers();
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.selectedIndex.update((i) => (i + 1) % Math.max(users.length, 1));
break;
case 'ArrowUp':
event.preventDefault();
this.selectedIndex.update((i) => (i - 1 + users.length) % Math.max(users.length, 1));
break;
case 'Enter':
event.preventDefault();
if (users.length > 0) {
this.selectMention(users[this.selectedIndex()]);
}
break;
case 'Escape':
this.showPopup.set(false);
break;
}
return;
}
if (event.key === 'Enter' && !event.shiftKey && !this.multiline()) {
event.preventDefault();
this.onSubmit();
}
}
selectMention(user: NtfMention): void {
const el = this.inputRef()?.nativeElement;
if (!el) return;
const cursorPos = el.selectionStart ?? this.textValue().length;
const result = insertMention(this.textValue(), cursorPos, user);
this.textValue.set(result.text);
this.valueChange.emit(result.text);
this.showPopup.set(false);
this.mentionSelect.emit({ userId: user.id, userName: user.name });
setTimeout(() => {
el.focus();
el.setSelectionRange(result.cursorPos, result.cursorPos);
});
}
onSubmit(): void {
const val = this.textValue().trim();
if (val) {
this.submit.emit(val);
this.textValue.set('');
this.valueChange.emit('');
}
}
onBlur(): void {
setTimeout(() => this.showPopup.set(false), 200);
}
}

View File

@@ -0,0 +1 @@
export { NtfThreadComponent } from './ntf-thread.component';

View File

@@ -0,0 +1,70 @@
<div class="ntf-thread">
@for (comment of comments(); track comment.id) {
<div class="ntf-thread__item">
<ntf-comment
[comment]="comment"
[currentUserId]="currentUserId()"
(reply)="onReply($event)"
(edit)="onEdit($event)"
(delete)="onDelete($event)"
(react)="onReact($event)"
/>
<!-- Reply input -->
@if (replyingTo() === comment.id && allowReply()) {
<div class="ntf-thread__reply-form">
<ntf-mention-input
[users]="mentionUsers()"
placeholder="Write a reply..."
[multiline]="false"
(submit)="onSubmitReply($event, comment.id)"
/>
<button class="ntf-thread__reply-cancel" (click)="cancelReply()">Cancel</button>
</div>
}
<!-- Nested replies -->
@if (comment.replies?.length) {
<div class="ntf-thread__replies">
<div class="ntf-thread__connector"></div>
@for (reply of comment.replies; track reply.id) {
<div class="ntf-thread__reply-item">
<ntf-comment
[comment]="reply"
[currentUserId]="currentUserId()"
(reply)="onReply($event)"
(edit)="onEdit($event)"
(delete)="onDelete($event)"
(react)="onReact($event)"
/>
<!-- Reply to reply -->
@if (replyingTo() === reply.id && allowReply()) {
<div class="ntf-thread__reply-form">
<ntf-mention-input
[users]="mentionUsers()"
placeholder="Write a reply..."
[multiline]="false"
(submit)="onSubmitReply($event, reply.id)"
/>
<button class="ntf-thread__reply-cancel" (click)="cancelReply()">Cancel</button>
</div>
}
</div>
}
</div>
}
</div>
}
<!-- New comment input -->
@if (allowReply()) {
<div class="ntf-thread__new-comment">
<ntf-mention-input
[users]="mentionUsers()"
placeholder="Write a comment..."
(submit)="onSubmitNew($event)"
/>
</div>
}
</div>

View File

@@ -0,0 +1,66 @@
:host {
display: block;
}
.ntf-thread {
display: flex;
flex-direction: column;
gap: 1rem;
}
.ntf-thread__item {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.ntf-thread__replies {
position: relative;
padding-left: 2.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.ntf-thread__connector {
position: absolute;
left: 16px;
top: 0;
bottom: 0;
width: var(--ntf-thread-connector-width);
background: var(--ntf-thread-connector-color);
border-radius: 1px;
}
.ntf-thread__reply-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ntf-thread__reply-form {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-left: 2.5rem;
}
.ntf-thread__reply-cancel {
align-self: flex-end;
padding: 0.125rem 0.5rem;
border: none;
background: transparent;
font-size: var(--font-size-xs, 0.75rem);
color: var(--ntf-item-time-color);
cursor: pointer;
transition: color var(--ntf-transition, 150ms ease-in-out);
&:hover {
color: var(--ntf-item-title-color);
}
}
.ntf-thread__new-comment {
padding-top: 0.5rem;
border-top: 1px solid var(--ntf-item-border);
}

View File

@@ -0,0 +1,77 @@
import { Component, ChangeDetectionStrategy, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { NtfComment } from '../../types/comment.types';
import type { NtfMention } from '../../types/notification.types';
import type {
NtfCommentSubmitEvent,
NtfCommentEditEvent,
NtfCommentDeleteEvent,
NtfReactionEvent,
NtfReplyEvent,
} from '../../types/event.types';
import { NtfCommentComponent } from '../ntf-comment/ntf-comment.component';
import { NtfMentionInputComponent } from '../ntf-mention-input/ntf-mention-input.component';
import { parseMentions } from '../../utils/mention.utils';
@Component({
selector: 'ntf-thread',
standalone: true,
imports: [CommonModule, NtfCommentComponent, NtfMentionInputComponent],
templateUrl: './ntf-thread.component.html',
styleUrl: './ntf-thread.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NtfThreadComponent {
readonly comments = input<NtfComment[]>([]);
readonly currentUserId = input('');
readonly allowReply = input(true);
readonly maxDepth = input(3);
readonly mentionUsers = input<NtfMention[]>([]);
readonly commentSubmit = output<NtfCommentSubmitEvent>();
readonly commentEdit = output<NtfCommentEditEvent>();
readonly commentDelete = output<NtfCommentDeleteEvent>();
readonly reaction = output<NtfReactionEvent>();
readonly replyEvent = output<NtfReplyEvent>();
readonly replyingTo = signal<string | null>(null);
onReply(event: NtfReplyEvent): void {
this.replyingTo.set(event.parentId);
this.replyEvent.emit(event);
}
onSubmitReply(content: string, parentId?: string): void {
const mentions = parseMentions(content);
this.commentSubmit.emit({
content,
mentions,
parentId,
});
this.replyingTo.set(null);
}
onSubmitNew(content: string): void {
const mentions = parseMentions(content);
this.commentSubmit.emit({
content,
mentions,
});
}
onEdit(event: NtfCommentEditEvent): void {
this.commentEdit.emit(event);
}
onDelete(event: NtfCommentDeleteEvent): void {
this.commentDelete.emit(event);
}
onReact(event: NtfReactionEvent): void {
this.reaction.emit(event);
}
cancelReply(): void {
this.replyingTo.set(null);
}
}

53
src/index.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* Notification Elements UI
* Main library entry point
*
* Angular components for notification center, real-time feeds, inbox,
* comment threads, and @mentions powered by @sda/base-ui
*
* @example
* ```typescript
* import { Component, signal } from '@angular/core';
* import {
* NtfBellComponent, NtfFeedComponent, NtfCenterComponent,
* NtfItemDefDirective, type NtfNotification,
* } from '@sda/notification-elements-ui';
*
* @Component({
* standalone: true,
* imports: [NtfBellComponent, NtfFeedComponent, NtfItemDefDirective],
* template: `
* <ntf-bell [count]="unreadCount()" (bellClick)="toggleFeed()" />
* <ntf-feed
* [notifications]="notifications()"
* [grouped]="true"
* (notificationClick)="onNotificationClick($event)"
* (markAllRead)="onMarkAllRead()"
* >
* <ng-template ntfItemDef let-notification>
* <div class="custom-item">{{ notification.title }}</div>
* </ng-template>
* </ntf-feed>
* `
* })
* export class AppComponent {
* notifications = signal<NtfNotification[]>([...]);
* unreadCount = computed(() => this.notifications().filter(n => n.status === 'unread').length);
* }
* ```
*/
// Types
export * from './types';
// Utils
export * from './utils';
// Providers
export * from './providers';
// Services
export * from './services';
// Components
export * from './components';

1
src/providers/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './notification-config.provider';

View File

@@ -0,0 +1,27 @@
import { InjectionToken, makeEnvironmentProviders, EnvironmentProviders } from '@angular/core';
import type { NtfConfig } from '../types/config.types';
export const DEFAULT_NOTIFICATION_CONFIG: NtfConfig = {
maxVisible: 50,
groupBy: 'date',
autoMarkReadDelay: 3000,
showTimestamps: true,
relativeTime: true,
allowArchive: true,
allowMarkAllRead: true,
mentionTrigger: '@',
mentionDebounce: 300,
commentMaxLength: 2000,
locale: 'en-US',
};
export const NOTIFICATION_CONFIG = new InjectionToken<NtfConfig>('NOTIFICATION_CONFIG', {
providedIn: 'root',
factory: () => DEFAULT_NOTIFICATION_CONFIG,
});
export function provideNotificationConfig(config: Partial<NtfConfig> = {}): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: NOTIFICATION_CONFIG, useValue: { ...DEFAULT_NOTIFICATION_CONFIG, ...config } },
]);
}

3
src/services/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './notification.service';
export * from './notification-feed.service';
export * from './mention.service';

View File

@@ -0,0 +1,26 @@
import { Injectable, signal, computed } from '@angular/core';
import type { NtfMention } from '../types/notification.types';
@Injectable()
export class MentionService {
readonly users = signal<NtfMention[]>([]);
readonly query = signal('');
readonly searchResults = computed(() => {
const q = this.query().toLowerCase();
if (!q) return [];
return this.users().filter((u) => u.name.toLowerCase().includes(q));
});
setUsers(users: NtfMention[]): void {
this.users.set(users);
}
search(query: string): void {
this.query.set(query);
}
clear(): void {
this.query.set('');
}
}

View File

@@ -0,0 +1,46 @@
import { Injectable, signal } from '@angular/core';
import type { NtfNotification } from '../types/notification.types';
@Injectable()
export class NotificationFeedService {
readonly feedItems = signal<NtfNotification[]>([]);
readonly loading = signal(false);
readonly hasMore = signal(true);
private allItems: NtfNotification[] = [];
private pageSize = 20;
private currentPage = 0;
setItems(items: NtfNotification[]): void {
this.allItems = items;
this.currentPage = 0;
this.feedItems.set(items.slice(0, this.pageSize));
this.hasMore.set(items.length > this.pageSize);
}
addToFeed(notification: NtfNotification): void {
this.allItems = [notification, ...this.allItems];
this.feedItems.update((items) => [notification, ...items]);
}
loadMore(): void {
if (this.loading() || !this.hasMore()) return;
this.loading.set(true);
this.currentPage++;
const start = this.currentPage * this.pageSize;
const end = start + this.pageSize;
const newItems = this.allItems.slice(start, end);
this.feedItems.update((items) => [...items, ...newItems]);
this.hasMore.set(end < this.allItems.length);
this.loading.set(false);
}
refresh(): void {
this.currentPage = 0;
this.feedItems.set(this.allItems.slice(0, this.pageSize));
this.hasMore.set(this.allItems.length > this.pageSize);
}
}

View File

@@ -0,0 +1,56 @@
import { Injectable, inject, signal, computed } from '@angular/core';
import { NOTIFICATION_CONFIG } from '../providers/notification-config.provider';
import type { NtfNotification, NtfGroup } from '../types/notification.types';
import { groupByDate } from '../utils/time.utils';
@Injectable()
export class NotificationService {
private config = inject(NOTIFICATION_CONFIG);
readonly notifications = signal<NtfNotification[]>([]);
readonly unreadCount = computed(() =>
this.notifications().filter((n) => n.status === 'unread').length,
);
readonly grouped = computed<NtfGroup[]>(() => {
const items = this.notifications().filter((n) => n.status !== 'archived');
const dateGroups = groupByDate(items);
return dateGroups.map((g) => ({
id: g.label.toLowerCase().replace(/\s+/g, '-'),
label: g.label,
notifications: g.items,
}));
});
add(notification: NtfNotification): void {
this.notifications.update((list) => [notification, ...list]);
}
markRead(notificationId: string): void {
this.notifications.update((list) =>
list.map((n) => (n.id === notificationId ? { ...n, status: 'read' as const } : n)),
);
}
markAllRead(): void {
this.notifications.update((list) =>
list.map((n) => (n.status === 'unread' ? { ...n, status: 'read' as const } : n)),
);
}
archive(notificationId: string): void {
this.notifications.update((list) =>
list.map((n) => (n.id === notificationId ? { ...n, status: 'archived' as const } : n)),
);
}
remove(notificationId: string): void {
this.notifications.update((list) => list.filter((n) => n.id !== notificationId));
}
clear(): void {
this.notifications.set([]);
}
}

2
src/styles/_index.scss Normal file
View File

@@ -0,0 +1,2 @@
@forward 'tokens';
@forward 'mixins';

118
src/styles/_mixins.scss Normal file
View File

@@ -0,0 +1,118 @@
@mixin ntf-container {
display: block;
position: relative;
width: 100%;
font-family: var(--ntf-font-family, inherit);
}
@mixin ntf-flex-center {
display: flex;
align-items: center;
}
@mixin ntf-flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
@mixin ntf-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin ntf-line-clamp($lines: 2) {
display: -webkit-box;
-webkit-line-clamp: $lines;
-webkit-box-orient: vertical;
overflow: hidden;
}
@mixin ntf-item-base {
background: var(--ntf-item-bg);
border-bottom: 1px solid var(--ntf-item-border);
padding: var(--ntf-item-padding);
transition:
background-color var(--ntf-transition),
border-color var(--ntf-transition);
cursor: pointer;
&:hover {
background: var(--ntf-item-hover-bg);
}
}
@mixin ntf-comment-base {
background: var(--ntf-comment-bg);
border: 1px solid var(--ntf-comment-border);
border-radius: var(--ntf-comment-radius);
padding: var(--ntf-comment-padding);
transition:
background-color var(--ntf-transition),
border-color var(--ntf-transition);
}
@mixin ntf-avatar($size: 36px) {
width: $size;
height: $size;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
@mixin ntf-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: var(--ntf-badge-size);
height: var(--ntf-badge-size);
padding: 0 6px;
border-radius: 999px;
background: var(--ntf-badge-bg);
color: var(--ntf-badge-color);
font-size: 0.6875rem;
font-weight: 600;
line-height: 1;
}
@mixin ntf-scrollbar($width: 4px) {
&::-webkit-scrollbar {
width: $width;
height: $width;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--ntf-item-time-color);
border-radius: $width;
opacity: 0.4;
&:hover {
background: var(--ntf-item-body-color);
}
}
scrollbar-width: thin;
scrollbar-color: var(--ntf-item-time-color) transparent;
}
@mixin ntf-hover-lift {
transition:
box-shadow 200ms var(--ntf-ease-smooth),
transform 250ms var(--ntf-ease-spring);
&:hover {
transform: translateY(-1px);
}
}
@mixin ntf-popup {
background: var(--ntf-mention-popup-bg);
border: 1px solid var(--ntf-feed-border);
border-radius: var(--ntf-mention-popup-radius);
box-shadow: var(--ntf-mention-popup-shadow);
}

143
src/styles/_tokens.scss Normal file
View File

@@ -0,0 +1,143 @@
:root {
// Bell
--ntf-bell-size: 40px;
--ntf-bell-color: var(--color-text-secondary, #6b7280);
--ntf-bell-hover-bg: var(--color-bg-hover, #f3f4f6);
--ntf-badge-bg: var(--color-error-500, #ef4444);
--ntf-badge-color: #ffffff;
--ntf-badge-size: 20px;
// Feed / Center
--ntf-feed-bg: var(--color-bg-primary, #ffffff);
--ntf-feed-border: var(--color-border-primary, #e5e7eb);
--ntf-feed-radius: var(--radius-container-lg, 1rem);
--ntf-feed-shadow: 0 10px 15px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.05);
--ntf-feed-width: 420px;
--ntf-feed-max-height: 480px;
// Item
--ntf-item-bg: var(--color-bg-primary, #ffffff);
--ntf-item-hover-bg: var(--color-bg-hover, #f9fafb);
--ntf-item-unread-bg: var(--color-primary-50, #eff6ff);
--ntf-item-unread-border: var(--color-primary-500, #3b82f6);
--ntf-item-padding: 1rem;
--ntf-item-gap: 0.75rem;
--ntf-item-border: var(--color-border-primary, #e5e7eb);
--ntf-item-title-color: var(--color-text-primary, #111827);
--ntf-item-title-font-size: var(--font-size-sm, 0.875rem);
--ntf-item-title-font-weight: 500;
--ntf-item-body-color: var(--color-text-secondary, #6b7280);
--ntf-item-body-font-size: var(--font-size-xs, 0.75rem);
--ntf-item-time-color: var(--color-text-muted, #9ca3af);
--ntf-item-time-font-size: var(--font-size-xs, 0.75rem);
// Comment / Thread
--ntf-comment-bg: var(--color-bg-secondary, #f9fafb);
--ntf-comment-border: var(--color-border-primary, #e5e7eb);
--ntf-comment-radius: 0.75rem;
--ntf-comment-padding: 1rem;
--ntf-comment-author-color: var(--color-text-primary, #111827);
--ntf-comment-author-font-size: var(--font-size-sm, 0.875rem);
--ntf-comment-author-font-weight: 600;
--ntf-comment-content-color: var(--color-text-secondary, #374151);
--ntf-comment-content-font-size: var(--font-size-sm, 0.875rem);
--ntf-thread-connector-color: var(--color-border-primary, #e5e7eb);
--ntf-thread-connector-width: 2px;
// Inbox
--ntf-inbox-item-bg: var(--color-bg-primary, #ffffff);
--ntf-inbox-item-hover-bg: var(--color-bg-hover, #f9fafb);
--ntf-inbox-unread-weight: 600;
--ntf-inbox-star-color: #eab308;
--ntf-inbox-selected-bg: var(--color-primary-50, #eff6ff);
// Mention
--ntf-mention-bg: var(--color-primary-50, #eff6ff);
--ntf-mention-color: var(--color-primary-700, #1d4ed8);
--ntf-mention-popup-bg: var(--color-bg-primary, #ffffff);
--ntf-mention-popup-shadow: 0 10px 15px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.05);
--ntf-mention-popup-radius: 0.5rem;
--ntf-mention-popup-max-height: 200px;
// Empty state
--ntf-empty-color: var(--color-text-muted, #9ca3af);
--ntf-empty-icon-size: 48px;
// Transitions
--ntf-transition: 150ms ease-in-out;
--ntf-transition-slow: 300ms ease-in-out;
// Easing
--ntf-ease-smooth: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ntf-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
@media (prefers-color-scheme: dark) {
:root {
--ntf-feed-bg: var(--color-bg-primary, #111827);
--ntf-feed-border: var(--color-border-primary, #1e2536);
--ntf-feed-shadow: 0 10px 15px rgba(0, 0, 0, 0.4), 0 4px 6px rgba(0, 0, 0, 0.3);
--ntf-item-bg: var(--color-bg-primary, #111827);
--ntf-item-hover-bg: var(--color-bg-hover, #1e2536);
--ntf-item-unread-bg: rgba(59, 130, 246, 0.1);
--ntf-item-border: var(--color-border-primary, #1e2536);
--ntf-item-title-color: var(--color-text-primary, #f9fafb);
--ntf-item-body-color: var(--color-text-secondary, #9ca3af);
--ntf-item-time-color: var(--color-text-muted, #6b7280);
--ntf-comment-bg: var(--color-bg-secondary, #161b26);
--ntf-comment-border: var(--color-border-primary, #1e2536);
--ntf-comment-author-color: var(--color-text-primary, #f9fafb);
--ntf-comment-content-color: var(--color-text-secondary, #9ca3af);
--ntf-thread-connector-color: var(--color-border-primary, #1e2536);
--ntf-inbox-item-bg: var(--color-bg-primary, #111827);
--ntf-inbox-item-hover-bg: var(--color-bg-hover, #1e2536);
--ntf-inbox-selected-bg: rgba(59, 130, 246, 0.1);
--ntf-mention-bg: rgba(59, 130, 246, 0.15);
--ntf-mention-color: #93bbfd;
--ntf-mention-popup-bg: var(--color-bg-primary, #111827);
--ntf-mention-popup-shadow: 0 10px 15px rgba(0, 0, 0, 0.5), 0 4px 6px rgba(0, 0, 0, 0.3);
--ntf-bell-color: var(--color-text-secondary, #9ca3af);
--ntf-bell-hover-bg: var(--color-bg-hover, #1e2536);
--ntf-empty-color: var(--color-text-muted, #6b7280);
}
}
[data-mode="dark"] {
--ntf-feed-bg: var(--color-bg-primary, #111827);
--ntf-feed-border: var(--color-border-primary, #1e2536);
--ntf-feed-shadow: 0 10px 15px rgba(0, 0, 0, 0.4), 0 4px 6px rgba(0, 0, 0, 0.3);
--ntf-item-bg: var(--color-bg-primary, #111827);
--ntf-item-hover-bg: var(--color-bg-hover, #1e2536);
--ntf-item-unread-bg: rgba(59, 130, 246, 0.1);
--ntf-item-border: var(--color-border-primary, #1e2536);
--ntf-item-title-color: var(--color-text-primary, #f9fafb);
--ntf-item-body-color: var(--color-text-secondary, #9ca3af);
--ntf-item-time-color: var(--color-text-muted, #6b7280);
--ntf-comment-bg: var(--color-bg-secondary, #161b26);
--ntf-comment-border: var(--color-border-primary, #1e2536);
--ntf-comment-author-color: var(--color-text-primary, #f9fafb);
--ntf-comment-content-color: var(--color-text-secondary, #9ca3af);
--ntf-thread-connector-color: var(--color-border-primary, #1e2536);
--ntf-inbox-item-bg: var(--color-bg-primary, #111827);
--ntf-inbox-item-hover-bg: var(--color-bg-hover, #1e2536);
--ntf-inbox-selected-bg: rgba(59, 130, 246, 0.1);
--ntf-mention-bg: rgba(59, 130, 246, 0.15);
--ntf-mention-color: #93bbfd;
--ntf-mention-popup-bg: var(--color-bg-primary, #111827);
--ntf-mention-popup-shadow: 0 10px 15px rgba(0, 0, 0, 0.5), 0 4px 6px rgba(0, 0, 0, 0.3);
--ntf-bell-color: var(--color-text-secondary, #9ca3af);
--ntf-bell-hover-bg: var(--color-bg-hover, #1e2536);
--ntf-empty-color: var(--color-text-muted, #6b7280);
}

View File

@@ -0,0 +1,28 @@
export interface NtfReaction {
emoji: string;
count: number;
reacted: boolean;
}
export interface NtfAttachment {
id: string;
name: string;
url: string;
type: string;
size?: number;
}
export interface NtfComment {
id: string;
authorId: string;
authorName: string;
authorAvatar?: string;
content: string;
mentions?: string[];
timestamp: Date | string;
editedAt?: Date | string;
reactions?: NtfReaction[];
replies?: NtfComment[];
parentId?: string;
attachments?: NtfAttachment[];
}

13
src/types/config.types.ts Normal file
View File

@@ -0,0 +1,13 @@
export interface NtfConfig {
maxVisible: number;
groupBy: 'date' | 'category' | 'priority' | 'none';
autoMarkReadDelay: number;
showTimestamps: boolean;
relativeTime: boolean;
allowArchive: boolean;
allowMarkAllRead: boolean;
mentionTrigger: string;
mentionDebounce: number;
commentMaxLength: number;
locale: string;
}

83
src/types/event.types.ts Normal file
View File

@@ -0,0 +1,83 @@
import type { NtfNotification, NtfAction, NtfCategory } from './notification.types';
import type { NtfComment } from './comment.types';
import type { NtfInboxMessage } from './inbox.types';
// Notification events
export interface NtfNotificationClickEvent {
notification: NtfNotification;
index: number;
}
export interface NtfActionClickEvent {
notification: NtfNotification;
action: NtfAction;
}
export interface NtfMarkReadEvent {
notificationId: string;
}
export interface NtfMarkAllReadEvent {
count: number;
}
export interface NtfArchiveEvent {
notificationId: string;
}
export interface NtfDeleteEvent {
notificationId: string;
}
export interface NtfFilterChangeEvent {
filter: NtfCategory | 'all';
}
// Comment events
export interface NtfCommentSubmitEvent {
content: string;
mentions: string[];
parentId?: string;
}
export interface NtfCommentEditEvent {
comment: NtfComment;
newContent: string;
}
export interface NtfCommentDeleteEvent {
commentId: string;
}
export interface NtfReactionEvent {
commentId: string;
emoji: string;
}
export interface NtfReplyEvent {
parentId: string;
}
// Mention events
export interface NtfMentionSelectEvent {
userId: string;
userName: string;
}
export interface NtfMentionSearchEvent {
query: string;
}
// Inbox events
export interface NtfInboxSelectEvent {
message: NtfInboxMessage;
}
export interface NtfInboxStarEvent {
messageId: string;
starred: boolean;
}
export interface NtfInboxArchiveEvent {
messageId: string;
}

18
src/types/inbox.types.ts Normal file
View File

@@ -0,0 +1,18 @@
export interface NtfInboxLabel {
id: string;
name: string;
color: string;
}
export interface NtfInboxMessage {
id: string;
from: string;
subject: string;
preview: string;
body?: string;
timestamp: Date | string;
read: boolean;
starred: boolean;
labels?: NtfInboxLabel[];
threadId?: string;
}

5
src/types/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export * from './notification.types';
export * from './comment.types';
export * from './inbox.types';
export * from './config.types';
export * from './event.types';

View File

@@ -0,0 +1,56 @@
export type NtfPriority = 'urgent' | 'high' | 'normal' | 'low';
export type NtfCategory =
| 'system'
| 'mention'
| 'comment'
| 'assignment'
| 'update'
| 'alert'
| 'message'
| 'custom';
export type NtfStatus = 'unread' | 'read' | 'archived';
export interface NtfAction {
id: string;
label: string;
icon?: string;
variant?: 'primary' | 'secondary' | 'danger';
href?: string;
}
export interface NtfSender {
id: string;
name: string;
avatar?: string;
email?: string;
}
export interface NtfMention {
id: string;
name: string;
avatar?: string;
}
export interface NtfNotification {
id: string;
title: string;
body?: string;
category: NtfCategory;
priority?: NtfPriority;
status: NtfStatus;
timestamp: Date | string;
sender?: NtfSender;
avatar?: string;
icon?: string;
actions?: NtfAction[];
groupId?: string;
metadata?: Record<string, unknown>;
}
export interface NtfGroup {
id: string;
label: string;
notifications: NtfNotification[];
}

68
src/utils/filter.utils.ts Normal file
View File

@@ -0,0 +1,68 @@
import type { NtfNotification, NtfCategory, NtfPriority, NtfStatus } from '../types/notification.types';
export interface NtfFilter {
category?: NtfCategory;
priority?: NtfPriority;
status?: NtfStatus;
searchTerm?: string;
}
export function filterNotifications(
items: NtfNotification[],
filter: NtfFilter,
): NtfNotification[] {
return items.filter((item) => {
if (filter.category && item.category !== filter.category) return false;
if (filter.priority && item.priority !== filter.priority) return false;
if (filter.status && item.status !== filter.status) return false;
if (filter.searchTerm) {
const term = filter.searchTerm.toLowerCase();
const matchesTitle = item.title.toLowerCase().includes(term);
const matchesBody = item.body?.toLowerCase().includes(term) ?? false;
if (!matchesTitle && !matchesBody) return false;
}
return true;
});
}
export type NtfSortField = 'timestamp' | 'priority' | 'title';
export type NtfSortDirection = 'asc' | 'desc';
const PRIORITY_ORDER: Record<string, number> = {
urgent: 0,
high: 1,
normal: 2,
low: 3,
};
export function sortNotifications(
items: NtfNotification[],
field: NtfSortField,
direction: NtfSortDirection = 'desc',
): NtfNotification[] {
const sorted = [...items].sort((a, b) => {
let cmp = 0;
switch (field) {
case 'timestamp': {
const tA = new Date(a.timestamp).getTime();
const tB = new Date(b.timestamp).getTime();
cmp = tA - tB;
break;
}
case 'priority': {
const pA = PRIORITY_ORDER[a.priority ?? 'normal'] ?? 2;
const pB = PRIORITY_ORDER[b.priority ?? 'normal'] ?? 2;
cmp = pA - pB;
break;
}
case 'title':
cmp = a.title.localeCompare(b.title);
break;
}
return direction === 'desc' ? -cmp : cmp;
});
return sorted;
}

3
src/utils/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './time.utils';
export * from './mention.utils';
export * from './filter.utils';

View File

@@ -0,0 +1,48 @@
import type { NtfMention } from '../types/notification.types';
const MENTION_REGEX = /@(\w+)/g;
export function parseMentions(text: string): string[] {
const matches: string[] = [];
let match: RegExpExecArray | null;
const regex = new RegExp(MENTION_REGEX.source, MENTION_REGEX.flags);
while ((match = regex.exec(text)) !== null) {
matches.push(match[1]);
}
return matches;
}
export function highlightMentions(text: string, mentions: NtfMention[]): string {
const mentionNames = new Set(mentions.map((m) => m.name.toLowerCase()));
return text.replace(MENTION_REGEX, (full, name: string) => {
if (mentionNames.has(name.toLowerCase())) {
return `<span class="ntf-mention">${full}</span>`;
}
return full;
});
}
export function insertMention(text: string, cursorPos: number, mention: NtfMention): {
text: string;
cursorPos: number;
} {
const beforeCursor = text.substring(0, cursorPos);
const afterCursor = text.substring(cursorPos);
const triggerIndex = beforeCursor.lastIndexOf('@');
if (triggerIndex === -1) {
return { text, cursorPos };
}
const before = text.substring(0, triggerIndex);
const mentionText = `@${mention.name} `;
const newText = before + mentionText + afterCursor;
return {
text: newText,
cursorPos: before.length + mentionText.length,
};
}

63
src/utils/time.utils.ts Normal file
View File

@@ -0,0 +1,63 @@
export function formatRelativeTime(date: Date | string): string {
const now = new Date();
const d = date instanceof Date ? date : new Date(date);
const diffMs = now.getTime() - d.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'Just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHour < 24) return `${diffHour}h ago`;
if (diffDay === 1) return 'Yesterday';
if (diffDay < 7) return `${diffDay}d ago`;
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
export function formatTimestamp(date: Date | string, locale: string = 'en-US'): string {
const d = date instanceof Date ? date : new Date(date);
return d.toLocaleString(locale, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
export type DateGroup = 'Today' | 'Yesterday' | 'This Week' | 'Earlier';
export function getDateGroup(date: Date | string): DateGroup {
const now = new Date();
const d = date instanceof Date ? date : new Date(date);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 86400000);
const weekAgo = new Date(today.getTime() - 7 * 86400000);
if (d >= today) return 'Today';
if (d >= yesterday) return 'Yesterday';
if (d >= weekAgo) return 'This Week';
return 'Earlier';
}
export function groupByDate<T extends { timestamp: Date | string }>(
items: T[],
): { label: DateGroup; items: T[] }[] {
const groups = new Map<DateGroup, T[]>();
const order: DateGroup[] = ['Today', 'Yesterday', 'This Week', 'Earlier'];
for (const item of items) {
const group = getDateGroup(item.timestamp);
if (!groups.has(group)) {
groups.set(group, []);
}
groups.get(group)!.push(item);
}
return order
.filter((label) => groups.has(label))
.map((label) => ({ label, items: groups.get(label)! }));
}

28
tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "dom"],
"useDefineForClassFields": false
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}