@cyguin/notify 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +27 -0
- package/LICENSE +7 -0
- package/README.md +152 -0
- package/dist/adapters/postgres.cjs +7 -0
- package/dist/adapters/postgres.d.cts +5 -0
- package/dist/adapters/postgres.d.ts +5 -0
- package/dist/adapters/postgres.js +7 -0
- package/dist/adapters/sqlite.cjs +9 -0
- package/dist/adapters/sqlite.d.cts +6 -0
- package/dist/adapters/sqlite.d.ts +6 -0
- package/dist/adapters/sqlite.js +9 -0
- package/dist/chunk-4SP667TN.js +33 -0
- package/dist/chunk-DBFBHOZI.cjs +82 -0
- package/dist/chunk-HW23IB3R.cjs +59 -0
- package/dist/chunk-N3OMUVHL.cjs +33 -0
- package/dist/chunk-NJEVZBJ7.cjs +67 -0
- package/dist/chunk-QHPFQN2C.js +82 -0
- package/dist/chunk-VKPJWS2D.js +59 -0
- package/dist/chunk-WTNWBHMC.js +255 -0
- package/dist/chunk-WZ4RNT3A.js +67 -0
- package/dist/chunk-YWA2XDPM.cjs +255 -0
- package/dist/index.cjs +32 -0
- package/dist/index.d.cts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +32 -0
- package/dist/next.cjs +6 -0
- package/dist/next.d.cts +25 -0
- package/dist/next.d.ts +25 -0
- package/dist/next.js +6 -0
- package/dist/react.cjs +6 -0
- package/dist/react.d.cts +21 -0
- package/dist/react.d.ts +21 -0
- package/dist/react.js +6 -0
- package/dist/types-Q62lBJZ-.d.cts +25 -0
- package/dist/types-Q62lBJZ-.d.ts +25 -0
- package/package.json +71 -0
- package/src/adapters/index.ts +2 -0
- package/src/adapters/postgres.ts +60 -0
- package/src/adapters/sqlite.ts +82 -0
- package/src/components/NotificationBell.tsx +292 -0
- package/src/components/index.ts +2 -0
- package/src/di.ts +19 -0
- package/src/handlers/route.ts +52 -0
- package/src/index.ts +8 -0
- package/src/notify.ts +7 -0
- package/src/types.ts +22 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +16 -0
package/dist/next.d.cts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { N as NotificationRecord } from './types-Q62lBJZ-.cjs';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
|
|
4
|
+
interface NotifyHandlerOptions {
|
|
5
|
+
secret?: string;
|
|
6
|
+
}
|
|
7
|
+
declare function createNotifyHandler(_options?: NotifyHandlerOptions): {
|
|
8
|
+
GET(request: Request): Promise<NextResponse<{
|
|
9
|
+
error: string;
|
|
10
|
+
}> | NextResponse<{
|
|
11
|
+
notifications: NotificationRecord[];
|
|
12
|
+
}>>;
|
|
13
|
+
POST(request: Request): Promise<NextResponse<{
|
|
14
|
+
error: string;
|
|
15
|
+
}> | NextResponse<{
|
|
16
|
+
notification: NotificationRecord;
|
|
17
|
+
}>>;
|
|
18
|
+
PATCH(request: Request): Promise<NextResponse<{
|
|
19
|
+
error: string;
|
|
20
|
+
}> | NextResponse<{
|
|
21
|
+
ok: boolean;
|
|
22
|
+
}>>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export { type NotifyHandlerOptions, createNotifyHandler };
|
package/dist/next.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { N as NotificationRecord } from './types-Q62lBJZ-.js';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
|
|
4
|
+
interface NotifyHandlerOptions {
|
|
5
|
+
secret?: string;
|
|
6
|
+
}
|
|
7
|
+
declare function createNotifyHandler(_options?: NotifyHandlerOptions): {
|
|
8
|
+
GET(request: Request): Promise<NextResponse<{
|
|
9
|
+
error: string;
|
|
10
|
+
}> | NextResponse<{
|
|
11
|
+
notifications: NotificationRecord[];
|
|
12
|
+
}>>;
|
|
13
|
+
POST(request: Request): Promise<NextResponse<{
|
|
14
|
+
error: string;
|
|
15
|
+
}> | NextResponse<{
|
|
16
|
+
notification: NotificationRecord;
|
|
17
|
+
}>>;
|
|
18
|
+
PATCH(request: Request): Promise<NextResponse<{
|
|
19
|
+
error: string;
|
|
20
|
+
}> | NextResponse<{
|
|
21
|
+
ok: boolean;
|
|
22
|
+
}>>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export { type NotifyHandlerOptions, createNotifyHandler };
|
package/dist/next.js
ADDED
package/dist/react.cjs
ADDED
package/dist/react.d.cts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
|
|
3
|
+
interface Notification {
|
|
4
|
+
id: string;
|
|
5
|
+
userId: string;
|
|
6
|
+
title: string;
|
|
7
|
+
body: string;
|
|
8
|
+
href?: string;
|
|
9
|
+
readAt?: number;
|
|
10
|
+
createdAt: number;
|
|
11
|
+
}
|
|
12
|
+
interface NotificationBellProps {
|
|
13
|
+
userId?: string;
|
|
14
|
+
pollInterval?: number;
|
|
15
|
+
maxVisible?: number;
|
|
16
|
+
className?: string;
|
|
17
|
+
onToggle?: (open: boolean) => void;
|
|
18
|
+
}
|
|
19
|
+
declare function NotificationBell({ userId, pollInterval, maxVisible, className, onToggle, }: NotificationBellProps): react_jsx_runtime.JSX.Element;
|
|
20
|
+
|
|
21
|
+
export { type Notification, NotificationBell, type NotificationBellProps };
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
|
|
3
|
+
interface Notification {
|
|
4
|
+
id: string;
|
|
5
|
+
userId: string;
|
|
6
|
+
title: string;
|
|
7
|
+
body: string;
|
|
8
|
+
href?: string;
|
|
9
|
+
readAt?: number;
|
|
10
|
+
createdAt: number;
|
|
11
|
+
}
|
|
12
|
+
interface NotificationBellProps {
|
|
13
|
+
userId?: string;
|
|
14
|
+
pollInterval?: number;
|
|
15
|
+
maxVisible?: number;
|
|
16
|
+
className?: string;
|
|
17
|
+
onToggle?: (open: boolean) => void;
|
|
18
|
+
}
|
|
19
|
+
declare function NotificationBell({ userId, pollInterval, maxVisible, className, onToggle, }: NotificationBellProps): react_jsx_runtime.JSX.Element;
|
|
20
|
+
|
|
21
|
+
export { type Notification, NotificationBell, type NotificationBellProps };
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
interface NotificationRecord {
|
|
2
|
+
id: string;
|
|
3
|
+
userId: string;
|
|
4
|
+
title: string;
|
|
5
|
+
body: string;
|
|
6
|
+
href?: string;
|
|
7
|
+
readAt?: number;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
}
|
|
10
|
+
interface NotifyOptions {
|
|
11
|
+
title: string;
|
|
12
|
+
body: string;
|
|
13
|
+
href?: string;
|
|
14
|
+
}
|
|
15
|
+
interface NotificationAdapter {
|
|
16
|
+
create(notification: Omit<NotificationRecord, 'id' | 'createdAt'>): Promise<NotificationRecord>;
|
|
17
|
+
findManyByUser(userId: string, options?: {
|
|
18
|
+
limit?: number;
|
|
19
|
+
offset?: number;
|
|
20
|
+
}): Promise<NotificationRecord[]>;
|
|
21
|
+
markRead(id: string, userId: string): Promise<void>;
|
|
22
|
+
countUnread(userId: string): Promise<number>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type { NotificationRecord as N, NotificationAdapter as a, NotifyOptions as b };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
interface NotificationRecord {
|
|
2
|
+
id: string;
|
|
3
|
+
userId: string;
|
|
4
|
+
title: string;
|
|
5
|
+
body: string;
|
|
6
|
+
href?: string;
|
|
7
|
+
readAt?: number;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
}
|
|
10
|
+
interface NotifyOptions {
|
|
11
|
+
title: string;
|
|
12
|
+
body: string;
|
|
13
|
+
href?: string;
|
|
14
|
+
}
|
|
15
|
+
interface NotificationAdapter {
|
|
16
|
+
create(notification: Omit<NotificationRecord, 'id' | 'createdAt'>): Promise<NotificationRecord>;
|
|
17
|
+
findManyByUser(userId: string, options?: {
|
|
18
|
+
limit?: number;
|
|
19
|
+
offset?: number;
|
|
20
|
+
}): Promise<NotificationRecord[]>;
|
|
21
|
+
markRead(id: string, userId: string): Promise<void>;
|
|
22
|
+
countUnread(userId: string): Promise<number>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type { NotificationRecord as N, NotificationAdapter as a, NotifyOptions as b };
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cyguin/notify",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Server-triggered in-app notifications for Next.js",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/cyguin/notify"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"module": "dist/index.mjs",
|
|
12
|
+
"types": "dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts"
|
|
17
|
+
},
|
|
18
|
+
"./next": {
|
|
19
|
+
"import": "./dist/handlers/route.js",
|
|
20
|
+
"types": "./dist/handlers/route.d.ts"
|
|
21
|
+
},
|
|
22
|
+
"./react": {
|
|
23
|
+
"import": "./dist/components/index.js",
|
|
24
|
+
"types": "./dist/components/index.d.ts"
|
|
25
|
+
},
|
|
26
|
+
"./adapters/sqlite": {
|
|
27
|
+
"import": "./dist/adapters/sqlite.js",
|
|
28
|
+
"types": "./dist/adapters/sqlite.d.ts"
|
|
29
|
+
},
|
|
30
|
+
"./adapters/postgres": {
|
|
31
|
+
"import": "./dist/adapters/postgres.js",
|
|
32
|
+
"types": "./dist/adapters/postgres.d.ts"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup",
|
|
37
|
+
"dev": "tsup --watch"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"better-sqlite3": ">=11",
|
|
41
|
+
"next": ">=14",
|
|
42
|
+
"postgres": ">=3",
|
|
43
|
+
"react": ">=18"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"better-sqlite3": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"next": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"postgres": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"react": {
|
|
56
|
+
"optional": true
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/better-sqlite3": "^7.6.11",
|
|
61
|
+
"@types/node": "^20.0.0",
|
|
62
|
+
"@types/pg": "^8.11.0",
|
|
63
|
+
"@types/react": "^18.0.0",
|
|
64
|
+
"better-sqlite3": "^11.0.0",
|
|
65
|
+
"next": "^14.0.0",
|
|
66
|
+
"postgres": "^3.4.0",
|
|
67
|
+
"react": "^18.3.1",
|
|
68
|
+
"tsup": "^8.3.0",
|
|
69
|
+
"typescript": "^5.5.0"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid'
|
|
2
|
+
import type { NotificationAdapter, NotificationRecord } from '../types'
|
|
3
|
+
|
|
4
|
+
export function createPostgresAdapter(connectionString: string): NotificationAdapter {
|
|
5
|
+
const pool = (globalThis as Record<string, unknown>).__cyguin_postgres_pool as {
|
|
6
|
+
query: (sql: string, params: unknown[]) => Promise<{ rows: Record<string, unknown>[] }>
|
|
7
|
+
} | undefined
|
|
8
|
+
|
|
9
|
+
const sql = pool
|
|
10
|
+
? (q: string, p: unknown[]) => pool.query(q, p).then(r => r.rows)
|
|
11
|
+
: async (_q: string, _p: unknown[]) => { throw new Error('Postgres pool not initialized') }
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
async create(notification) {
|
|
15
|
+
const id = nanoid()
|
|
16
|
+
const createdAt = Date.now()
|
|
17
|
+
await sql(
|
|
18
|
+
`INSERT INTO notifications (id, user_id, title, body, href, created_at)
|
|
19
|
+
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
20
|
+
[id, notification.userId, notification.title, notification.body, notification.href ?? null, createdAt]
|
|
21
|
+
)
|
|
22
|
+
return { id, ...notification, createdAt }
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
async findManyByUser(userId, { limit = 20, offset = 0 } = {}) {
|
|
26
|
+
const rows = await sql(
|
|
27
|
+
`SELECT id, user_id, title, body, href, read_at, created_at
|
|
28
|
+
FROM notifications
|
|
29
|
+
WHERE user_id = $1
|
|
30
|
+
ORDER BY created_at DESC
|
|
31
|
+
LIMIT $2 OFFSET $3`,
|
|
32
|
+
[userId, limit, offset]
|
|
33
|
+
)
|
|
34
|
+
return rows.map((row) => ({
|
|
35
|
+
id: row.id as string,
|
|
36
|
+
userId: row.user_id as string,
|
|
37
|
+
title: row.title as string,
|
|
38
|
+
body: row.body as string,
|
|
39
|
+
href: row.href as string | undefined,
|
|
40
|
+
readAt: row.read_at as number | undefined,
|
|
41
|
+
createdAt: row.created_at as number,
|
|
42
|
+
}))
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async markRead(id, userId) {
|
|
46
|
+
await sql(
|
|
47
|
+
`UPDATE notifications SET read_at = $1 WHERE id = $2 AND user_id = $3`,
|
|
48
|
+
[Date.now(), id, userId]
|
|
49
|
+
)
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
async countUnread(userId) {
|
|
53
|
+
const rows = await sql(
|
|
54
|
+
`SELECT COUNT(*) as count FROM notifications WHERE user_id = $1 AND read_at IS NULL`,
|
|
55
|
+
[userId]
|
|
56
|
+
)
|
|
57
|
+
return Number(rows[0]?.count ?? 0)
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import Database from 'better-sqlite3'
|
|
2
|
+
import { nanoid } from 'nanoid'
|
|
3
|
+
import type { NotificationAdapter, NotificationRecord } from '../types'
|
|
4
|
+
|
|
5
|
+
let _db: Database.Database | null = null
|
|
6
|
+
|
|
7
|
+
function getDb(): Database.Database {
|
|
8
|
+
if (!_db) {
|
|
9
|
+
_db = new Database(':memory:')
|
|
10
|
+
_db.pragma('journal_mode = WAL')
|
|
11
|
+
_db.exec(`
|
|
12
|
+
CREATE TABLE IF NOT EXISTS notifications (
|
|
13
|
+
id TEXT PRIMARY KEY,
|
|
14
|
+
user_id TEXT NOT NULL,
|
|
15
|
+
title TEXT NOT NULL,
|
|
16
|
+
body TEXT NOT NULL,
|
|
17
|
+
href TEXT,
|
|
18
|
+
read_at INTEGER,
|
|
19
|
+
created_at INTEGER NOT NULL
|
|
20
|
+
);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at);
|
|
23
|
+
`)
|
|
24
|
+
}
|
|
25
|
+
return _db
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rowToRecord(row: Record<string, unknown>): NotificationRecord {
|
|
29
|
+
return {
|
|
30
|
+
id: row.id as string,
|
|
31
|
+
userId: row.user_id as string,
|
|
32
|
+
title: row.title as string,
|
|
33
|
+
body: row.body as string,
|
|
34
|
+
href: row.href as string | undefined,
|
|
35
|
+
readAt: row.read_at as number | undefined,
|
|
36
|
+
createdAt: row.created_at as number,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const SQLiteNotificationAdapter: NotificationAdapter = {
|
|
41
|
+
async create(notification) {
|
|
42
|
+
const db = getDb()
|
|
43
|
+
const id = nanoid()
|
|
44
|
+
const createdAt = Date.now()
|
|
45
|
+
db.prepare(`
|
|
46
|
+
INSERT INTO notifications (id, user_id, title, body, href, created_at)
|
|
47
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
48
|
+
`).run(id, notification.userId, notification.title, notification.body, notification.href ?? null, createdAt)
|
|
49
|
+
return { id, ...notification, createdAt }
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
async findManyByUser(userId, { limit = 20, offset = 0 } = {}) {
|
|
53
|
+
const db = getDb()
|
|
54
|
+
const rows = db.prepare(`
|
|
55
|
+
SELECT id, user_id, title, body, href, read_at, created_at
|
|
56
|
+
FROM notifications
|
|
57
|
+
WHERE user_id = ?
|
|
58
|
+
ORDER BY created_at DESC
|
|
59
|
+
LIMIT ? OFFSET ?
|
|
60
|
+
`).all(userId, limit, offset) as Record<string, unknown>[]
|
|
61
|
+
return rows.map(rowToRecord)
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async markRead(id, userId) {
|
|
65
|
+
const db = getDb()
|
|
66
|
+
db.prepare(`
|
|
67
|
+
UPDATE notifications SET read_at = ? WHERE id = ? AND user_id = ?
|
|
68
|
+
`).run(Date.now(), id, userId)
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async countUnread(userId) {
|
|
72
|
+
const db = getDb()
|
|
73
|
+
const row = db.prepare(`
|
|
74
|
+
SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND read_at IS NULL
|
|
75
|
+
`).get(userId) as { count: number }
|
|
76
|
+
return row.count
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createSQLiteAdapter(): NotificationAdapter {
|
|
81
|
+
return SQLiteNotificationAdapter
|
|
82
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface Notification {
|
|
6
|
+
id: string;
|
|
7
|
+
userId: string;
|
|
8
|
+
title: string;
|
|
9
|
+
body: string;
|
|
10
|
+
href?: string;
|
|
11
|
+
readAt?: number;
|
|
12
|
+
createdAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface NotificationBellProps {
|
|
16
|
+
userId?: string;
|
|
17
|
+
pollInterval?: number;
|
|
18
|
+
maxVisible?: number;
|
|
19
|
+
className?: string;
|
|
20
|
+
onToggle?: (open: boolean) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface FetchNotificationsResponse {
|
|
24
|
+
notifications: Notification[];
|
|
25
|
+
total?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function NotificationBell({
|
|
29
|
+
userId,
|
|
30
|
+
pollInterval = 30000,
|
|
31
|
+
maxVisible = 10,
|
|
32
|
+
className = '',
|
|
33
|
+
onToggle,
|
|
34
|
+
}: NotificationBellProps) {
|
|
35
|
+
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
36
|
+
const [unreadCount, setUnreadCount] = useState(0);
|
|
37
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
38
|
+
const [loading, setLoading] = useState(true);
|
|
39
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
|
|
41
|
+
const fetchNotifications = useCallback(async () => {
|
|
42
|
+
if (!userId) return;
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(`/api/notify?userId=${encodeURIComponent(userId)}&limit=${maxVisible}`);
|
|
45
|
+
const data: FetchNotificationsResponse = await res.json();
|
|
46
|
+
setNotifications(data.notifications ?? []);
|
|
47
|
+
setUnreadCount(data.notifications?.filter(n => !n.readAt).length ?? 0);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error('[NotificationBell] fetch failed:', err);
|
|
50
|
+
} finally {
|
|
51
|
+
setLoading(false);
|
|
52
|
+
}
|
|
53
|
+
}, [userId, maxVisible]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
fetchNotifications();
|
|
57
|
+
if (pollInterval > 0) {
|
|
58
|
+
const id = setInterval(fetchNotifications, pollInterval);
|
|
59
|
+
return () => clearInterval(id);
|
|
60
|
+
}
|
|
61
|
+
}, [fetchNotifications, pollInterval]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
function handleClickOutside(e: MouseEvent) {
|
|
65
|
+
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
66
|
+
setIsOpen(false);
|
|
67
|
+
onToggle?.(false);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (isOpen) {
|
|
71
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
72
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
73
|
+
}
|
|
74
|
+
}, [isOpen, onToggle]);
|
|
75
|
+
|
|
76
|
+
const handleNotificationClick = async (notification: Notification) => {
|
|
77
|
+
if (notification.href) {
|
|
78
|
+
window.location.href = notification.href;
|
|
79
|
+
}
|
|
80
|
+
if (!notification.readAt && userId) {
|
|
81
|
+
try {
|
|
82
|
+
await fetch(`/api/notify/${notification.id}/read?userId=${encodeURIComponent(userId)}`, {
|
|
83
|
+
method: 'PATCH',
|
|
84
|
+
});
|
|
85
|
+
setNotifications(prev =>
|
|
86
|
+
prev.map(n => n.id === notification.id ? { ...n, readAt: Date.now() } : n)
|
|
87
|
+
);
|
|
88
|
+
setUnreadCount(prev => Math.max(0, prev - 1));
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error('[NotificationBell] mark read failed:', err);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
setIsOpen(false);
|
|
94
|
+
onToggle?.(false);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const handleBellClick = () => {
|
|
98
|
+
setIsOpen(prev => {
|
|
99
|
+
onToggle?.(!prev);
|
|
100
|
+
return !prev;
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const badgeDisplay = unreadCount > 99 ? '99+' : unreadCount;
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<>
|
|
108
|
+
<style>{`
|
|
109
|
+
@keyframes cyguin-notify-spin {
|
|
110
|
+
to { transform: rotate(360deg); }
|
|
111
|
+
}
|
|
112
|
+
@keyframes cyguin-notify-fade-in {
|
|
113
|
+
from { opacity: 0; transform: translateY(-4px); }
|
|
114
|
+
to { opacity: 1; transform: translateY(0); }
|
|
115
|
+
}
|
|
116
|
+
.cyguin-notify-bell {
|
|
117
|
+
--cyguin-bg: #ffffff;
|
|
118
|
+
--cyguin-bg-subtle: #f5f5f5;
|
|
119
|
+
--cyguin-border: #e5e5e5;
|
|
120
|
+
--cyguin-border-focus: #f5a800;
|
|
121
|
+
--cyguin-fg: #0a0a0a;
|
|
122
|
+
--cyguin-fg-muted: #888888;
|
|
123
|
+
--cyguin-accent: #f5a800;
|
|
124
|
+
--cyguin-accent-dark: #c47f00;
|
|
125
|
+
--cyguin-accent-fg: #0a0a0a;
|
|
126
|
+
--cyguin-radius: 6px;
|
|
127
|
+
--cyguin-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
|
128
|
+
position: relative;
|
|
129
|
+
display: inline-flex;
|
|
130
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
131
|
+
}
|
|
132
|
+
.cyguin-notify-bell[data-theme="dark"] {
|
|
133
|
+
--cyguin-bg: #0a0a0a;
|
|
134
|
+
--cyguin-bg-subtle: #1a1a1a;
|
|
135
|
+
--cyguin-border: #2a2a2a;
|
|
136
|
+
--cyguin-fg: #f5f5f5;
|
|
137
|
+
--cyguin-fg-muted: #888888;
|
|
138
|
+
--cyguin-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
|
139
|
+
}
|
|
140
|
+
.cyguin-notify-bell-btn {
|
|
141
|
+
background: transparent;
|
|
142
|
+
border: 1px solid var(--cyguin-border);
|
|
143
|
+
border-radius: var(--cyguin-radius);
|
|
144
|
+
padding: 8px 10px;
|
|
145
|
+
cursor: pointer;
|
|
146
|
+
display: flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
justify-content: center;
|
|
149
|
+
color: var(--cyguin-fg);
|
|
150
|
+
transition: border-color 0.15s, background-color 0.15s;
|
|
151
|
+
position: relative;
|
|
152
|
+
}
|
|
153
|
+
.cyguin-notify-bell-btn:hover {
|
|
154
|
+
border-color: var(--cyguin-border-focus);
|
|
155
|
+
background-color: var(--cyguin-bg-subtle);
|
|
156
|
+
}
|
|
157
|
+
.cyguin-notify-badge {
|
|
158
|
+
position: absolute;
|
|
159
|
+
top: -4px;
|
|
160
|
+
right: -4px;
|
|
161
|
+
background-color: var(--cyguin-accent);
|
|
162
|
+
color: var(--cyguin-accent-fg);
|
|
163
|
+
font-size: 10px;
|
|
164
|
+
font-weight: 700;
|
|
165
|
+
min-width: 16px;
|
|
166
|
+
height: 16px;
|
|
167
|
+
border-radius: 8px;
|
|
168
|
+
display: flex;
|
|
169
|
+
align-items: center;
|
|
170
|
+
justify-content: center;
|
|
171
|
+
padding: 0 4px;
|
|
172
|
+
box-sizing: border-box;
|
|
173
|
+
}
|
|
174
|
+
.cyguin-notify-dropdown {
|
|
175
|
+
position: absolute;
|
|
176
|
+
top: calc(100% + 6px);
|
|
177
|
+
right: 0;
|
|
178
|
+
width: 320px;
|
|
179
|
+
max-height: 420px;
|
|
180
|
+
overflow-y: auto;
|
|
181
|
+
background-color: var(--cyguin-bg);
|
|
182
|
+
border: 1px solid var(--cyguin-border);
|
|
183
|
+
border-radius: var(--cyguin-radius);
|
|
184
|
+
box-shadow: var(--cyguin-shadow);
|
|
185
|
+
animation: cyguin-notify-fade-in 0.15s ease-out;
|
|
186
|
+
z-index: 1000;
|
|
187
|
+
}
|
|
188
|
+
.cyguin-notify-dropdown-header {
|
|
189
|
+
padding: 10px 14px;
|
|
190
|
+
border-bottom: 1px solid var(--cyguin-border);
|
|
191
|
+
font-size: 13px;
|
|
192
|
+
font-weight: 600;
|
|
193
|
+
color: var(--cyguin-fg);
|
|
194
|
+
}
|
|
195
|
+
.cyguin-notify-item {
|
|
196
|
+
display: flex;
|
|
197
|
+
flex-direction: column;
|
|
198
|
+
gap: 2px;
|
|
199
|
+
padding: 10px 14px;
|
|
200
|
+
border-bottom: 1px solid var(--cyguin-border);
|
|
201
|
+
cursor: pointer;
|
|
202
|
+
transition: background-color 0.1s;
|
|
203
|
+
}
|
|
204
|
+
.cyguin-notify-item:last-child {
|
|
205
|
+
border-bottom: none;
|
|
206
|
+
}
|
|
207
|
+
.cyguin-notify-item:hover {
|
|
208
|
+
background-color: var(--cyguin-bg-subtle);
|
|
209
|
+
}
|
|
210
|
+
.cyguin-notify-item.unread {
|
|
211
|
+
background-color: color-mix(in srgb, var(--cyguin-accent) 8%, transparent);
|
|
212
|
+
}
|
|
213
|
+
.cyguin-notify-item.unread:hover {
|
|
214
|
+
background-color: color-mix(in srgb, var(--cyguin-accent) 12%, transparent);
|
|
215
|
+
}
|
|
216
|
+
.cyguin-notify-item-title {
|
|
217
|
+
font-size: 13px;
|
|
218
|
+
font-weight: 500;
|
|
219
|
+
color: var(--cyguin-fg);
|
|
220
|
+
}
|
|
221
|
+
.cyguin-notify-item-body {
|
|
222
|
+
font-size: 12px;
|
|
223
|
+
color: var(--cyguin-fg-muted);
|
|
224
|
+
line-height: 1.4;
|
|
225
|
+
}
|
|
226
|
+
.cyguin-notify-empty {
|
|
227
|
+
padding: 24px 14px;
|
|
228
|
+
text-align: center;
|
|
229
|
+
font-size: 13px;
|
|
230
|
+
color: var(--cyguin-fg-muted);
|
|
231
|
+
}
|
|
232
|
+
.cyguin-notify-spinner {
|
|
233
|
+
width: 16px;
|
|
234
|
+
height: 16px;
|
|
235
|
+
border: 2px solid var(--cyguin-border);
|
|
236
|
+
border-top-color: var(--cyguin-accent);
|
|
237
|
+
border-radius: 50%;
|
|
238
|
+
animation: cyguin-notify-spin 0.6s linear infinite;
|
|
239
|
+
}
|
|
240
|
+
.cyguin-notify-loading {
|
|
241
|
+
display: flex;
|
|
242
|
+
align-items: center;
|
|
243
|
+
justify-content: center;
|
|
244
|
+
padding: 24px;
|
|
245
|
+
}
|
|
246
|
+
`}</style>
|
|
247
|
+
<div className={`cyguin-notify-bell ${className}`} data-theme="light" ref={dropdownRef}>
|
|
248
|
+
<button
|
|
249
|
+
className="cyguin-notify-bell-btn"
|
|
250
|
+
onClick={handleBellClick}
|
|
251
|
+
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
|
|
252
|
+
aria-expanded={isOpen}
|
|
253
|
+
aria-haspopup="true"
|
|
254
|
+
>
|
|
255
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
256
|
+
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
|
257
|
+
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
|
258
|
+
</svg>
|
|
259
|
+
{unreadCount > 0 && (
|
|
260
|
+
<span className="cyguin-notify-badge">{badgeDisplay}</span>
|
|
261
|
+
)}
|
|
262
|
+
</button>
|
|
263
|
+
{isOpen && (
|
|
264
|
+
<div className="cyguin-notify-dropdown" role="menu">
|
|
265
|
+
{loading ? (
|
|
266
|
+
<div className="cyguin-notify-loading">
|
|
267
|
+
<div className="cyguin-notify-spinner" />
|
|
268
|
+
</div>
|
|
269
|
+
) : notifications.length === 0 ? (
|
|
270
|
+
<div className="cyguin-notify-empty">No notifications</div>
|
|
271
|
+
) : (
|
|
272
|
+
<>
|
|
273
|
+
<div className="cyguin-notify-dropdown-header">Notifications</div>
|
|
274
|
+
{notifications.map(n => (
|
|
275
|
+
<div
|
|
276
|
+
key={n.id}
|
|
277
|
+
className={`cyguin-notify-item${!n.readAt ? ' unread' : ''}`}
|
|
278
|
+
onClick={() => handleNotificationClick(n)}
|
|
279
|
+
role="menuitem"
|
|
280
|
+
>
|
|
281
|
+
<span className="cyguin-notify-item-title">{n.title}</span>
|
|
282
|
+
<span className="cyguin-notify-item-body">{n.body}</span>
|
|
283
|
+
</div>
|
|
284
|
+
))}
|
|
285
|
+
</>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
</>
|
|
291
|
+
);
|
|
292
|
+
}
|