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:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
.angular/
|
||||||
|
*.tgz
|
||||||
8
build-for-dev.sh
Executable file
8
build-for-dev.sh
Executable 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
12
ng-package.json
Normal 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
3767
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal 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
11
src/components/index.ts
Normal 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';
|
||||||
1
src/components/ntf-bell/index.ts
Normal file
1
src/components/ntf-bell/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { NtfBellComponent } from './ntf-bell.component';
|
||||||
17
src/components/ntf-bell/ntf-bell.component.html
Normal file
17
src/components/ntf-bell/ntf-bell.component.html
Normal 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>
|
||||||
103
src/components/ntf-bell/ntf-bell.component.scss
Normal file
103
src/components/ntf-bell/ntf-bell.component.scss
Normal 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); }
|
||||||
|
}
|
||||||
28
src/components/ntf-bell/ntf-bell.component.ts
Normal file
28
src/components/ntf-bell/ntf-bell.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ntf-center/index.ts
Normal file
1
src/components/ntf-center/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { NtfCenterComponent } from './ntf-center.component';
|
||||||
30
src/components/ntf-center/ntf-center.component.html
Normal file
30
src/components/ntf-center/ntf-center.component.html
Normal 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>
|
||||||
62
src/components/ntf-center/ntf-center.component.scss
Normal file
62
src/components/ntf-center/ntf-center.component.scss
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/components/ntf-center/ntf-center.component.ts
Normal file
78
src/components/ntf-center/ntf-center.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ntf-comment/index.ts
Normal file
1
src/components/ntf-comment/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { NtfCommentComponent } from './ntf-comment.component';
|
||||||
91
src/components/ntf-comment/ntf-comment.component.html
Normal file
91
src/components/ntf-comment/ntf-comment.component.html
Normal 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>
|
||||||
249
src/components/ntf-comment/ntf-comment.component.scss
Normal file
249
src/components/ntf-comment/ntf-comment.component.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/components/ntf-comment/ntf-comment.component.ts
Normal file
93
src/components/ntf-comment/ntf-comment.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ntf-empty-state/index.ts
Normal file
1
src/components/ntf-empty-state/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { NtfEmptyStateComponent } from './ntf-empty-state.component';
|
||||||
82
src/components/ntf-empty-state/ntf-empty-state.component.ts
Normal file
82
src/components/ntf-empty-state/ntf-empty-state.component.ts
Normal 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>('');
|
||||||
|
}
|
||||||
1
src/components/ntf-feed-item/index.ts
Normal file
1
src/components/ntf-feed-item/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { NtfFeedItemComponent } from './ntf-feed-item.component';
|
||||||
103
src/components/ntf-feed-item/ntf-feed-item.component.html
Normal file
103
src/components/ntf-feed-item/ntf-feed-item.component.html
Normal 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>
|
||||||
|
}
|
||||||
189
src/components/ntf-feed-item/ntf-feed-item.component.scss
Normal file
189
src/components/ntf-feed-item/ntf-feed-item.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/components/ntf-feed-item/ntf-feed-item.component.ts
Normal file
80
src/components/ntf-feed-item/ntf-feed-item.component.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ntf-feed/index.ts
Normal file
1
src/components/ntf-feed/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { NtfFeedComponent } from './ntf-feed.component';
|
||||||
70
src/components/ntf-feed/ntf-feed.component.html
Normal file
70
src/components/ntf-feed/ntf-feed.component.html
Normal 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>
|
||||||
141
src/components/ntf-feed/ntf-feed.component.scss
Normal file
141
src/components/ntf-feed/ntf-feed.component.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/components/ntf-feed/ntf-feed.component.ts
Normal file
91
src/components/ntf-feed/ntf-feed.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ntf-inbox-item/index.ts
Normal file
1
src/components/ntf-inbox-item/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { NtfInboxItemComponent } from './ntf-inbox-item.component';
|
||||||
42
src/components/ntf-inbox-item/ntf-inbox-item.component.html
Normal file
42
src/components/ntf-inbox-item/ntf-inbox-item.component.html
Normal 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>
|
||||||
156
src/components/ntf-inbox-item/ntf-inbox-item.component.scss
Normal file
156
src/components/ntf-inbox-item/ntf-inbox-item.component.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/components/ntf-inbox-item/ntf-inbox-item.component.ts
Normal file
47
src/components/ntf-inbox-item/ntf-inbox-item.component.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ntf-inbox/index.ts
Normal file
1
src/components/ntf-inbox/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { NtfInboxComponent } from './ntf-inbox.component';
|
||||||
66
src/components/ntf-inbox/ntf-inbox.component.html
Normal file
66
src/components/ntf-inbox/ntf-inbox.component.html
Normal 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>
|
||||||
141
src/components/ntf-inbox/ntf-inbox.component.scss
Normal file
141
src/components/ntf-inbox/ntf-inbox.component.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/components/ntf-inbox/ntf-inbox.component.ts
Normal file
65
src/components/ntf-inbox/ntf-inbox.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ntf-item-def/index.ts
Normal file
1
src/components/ntf-item-def/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { NtfItemDefDirective, type NtfItemDefContext } from './ntf-item-def.directive';
|
||||||
25
src/components/ntf-item-def/ntf-item-def.directive.ts
Normal file
25
src/components/ntf-item-def/ntf-item-def.directive.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ntf-mention-input/index.ts
Normal file
1
src/components/ntf-mention-input/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { NtfMentionInputComponent } from './ntf-mention-input.component';
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/components/ntf-mention-input/ntf-mention-input.component.ts
Normal file
140
src/components/ntf-mention-input/ntf-mention-input.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ntf-thread/index.ts
Normal file
1
src/components/ntf-thread/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { NtfThreadComponent } from './ntf-thread.component';
|
||||||
70
src/components/ntf-thread/ntf-thread.component.html
Normal file
70
src/components/ntf-thread/ntf-thread.component.html
Normal 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>
|
||||||
66
src/components/ntf-thread/ntf-thread.component.scss
Normal file
66
src/components/ntf-thread/ntf-thread.component.scss
Normal 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);
|
||||||
|
}
|
||||||
77
src/components/ntf-thread/ntf-thread.component.ts
Normal file
77
src/components/ntf-thread/ntf-thread.component.ts
Normal 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
53
src/index.ts
Normal 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
1
src/providers/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './notification-config.provider';
|
||||||
27
src/providers/notification-config.provider.ts
Normal file
27
src/providers/notification-config.provider.ts
Normal 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
3
src/services/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './notification.service';
|
||||||
|
export * from './notification-feed.service';
|
||||||
|
export * from './mention.service';
|
||||||
26
src/services/mention.service.ts
Normal file
26
src/services/mention.service.ts
Normal 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('');
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/services/notification-feed.service.ts
Normal file
46
src/services/notification-feed.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/services/notification.service.ts
Normal file
56
src/services/notification.service.ts
Normal 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
2
src/styles/_index.scss
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@forward 'tokens';
|
||||||
|
@forward 'mixins';
|
||||||
118
src/styles/_mixins.scss
Normal file
118
src/styles/_mixins.scss
Normal 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
143
src/styles/_tokens.scss
Normal 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);
|
||||||
|
}
|
||||||
28
src/types/comment.types.ts
Normal file
28
src/types/comment.types.ts
Normal 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
13
src/types/config.types.ts
Normal 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
83
src/types/event.types.ts
Normal 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
18
src/types/inbox.types.ts
Normal 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
5
src/types/index.ts
Normal 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';
|
||||||
56
src/types/notification.types.ts
Normal file
56
src/types/notification.types.ts
Normal 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
68
src/utils/filter.utils.ts
Normal 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
3
src/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './time.utils';
|
||||||
|
export * from './mention.utils';
|
||||||
|
export * from './filter.utils';
|
||||||
48
src/utils/mention.utils.ts
Normal file
48
src/utils/mention.utils.ts
Normal 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
63
src/utils/time.utils.ts
Normal 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
28
tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user