@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.
Files changed (48) hide show
  1. package/.github/workflows/publish.yml +27 -0
  2. package/LICENSE +7 -0
  3. package/README.md +152 -0
  4. package/dist/adapters/postgres.cjs +7 -0
  5. package/dist/adapters/postgres.d.cts +5 -0
  6. package/dist/adapters/postgres.d.ts +5 -0
  7. package/dist/adapters/postgres.js +7 -0
  8. package/dist/adapters/sqlite.cjs +9 -0
  9. package/dist/adapters/sqlite.d.cts +6 -0
  10. package/dist/adapters/sqlite.d.ts +6 -0
  11. package/dist/adapters/sqlite.js +9 -0
  12. package/dist/chunk-4SP667TN.js +33 -0
  13. package/dist/chunk-DBFBHOZI.cjs +82 -0
  14. package/dist/chunk-HW23IB3R.cjs +59 -0
  15. package/dist/chunk-N3OMUVHL.cjs +33 -0
  16. package/dist/chunk-NJEVZBJ7.cjs +67 -0
  17. package/dist/chunk-QHPFQN2C.js +82 -0
  18. package/dist/chunk-VKPJWS2D.js +59 -0
  19. package/dist/chunk-WTNWBHMC.js +255 -0
  20. package/dist/chunk-WZ4RNT3A.js +67 -0
  21. package/dist/chunk-YWA2XDPM.cjs +255 -0
  22. package/dist/index.cjs +32 -0
  23. package/dist/index.d.cts +16 -0
  24. package/dist/index.d.ts +16 -0
  25. package/dist/index.js +32 -0
  26. package/dist/next.cjs +6 -0
  27. package/dist/next.d.cts +25 -0
  28. package/dist/next.d.ts +25 -0
  29. package/dist/next.js +6 -0
  30. package/dist/react.cjs +6 -0
  31. package/dist/react.d.cts +21 -0
  32. package/dist/react.d.ts +21 -0
  33. package/dist/react.js +6 -0
  34. package/dist/types-Q62lBJZ-.d.cts +25 -0
  35. package/dist/types-Q62lBJZ-.d.ts +25 -0
  36. package/package.json +71 -0
  37. package/src/adapters/index.ts +2 -0
  38. package/src/adapters/postgres.ts +60 -0
  39. package/src/adapters/sqlite.ts +82 -0
  40. package/src/components/NotificationBell.tsx +292 -0
  41. package/src/components/index.ts +2 -0
  42. package/src/di.ts +19 -0
  43. package/src/handlers/route.ts +52 -0
  44. package/src/index.ts +8 -0
  45. package/src/notify.ts +7 -0
  46. package/src/types.ts +22 -0
  47. package/tsconfig.json +22 -0
  48. package/tsup.config.ts +16 -0
@@ -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
@@ -0,0 +1,6 @@
1
+ import {
2
+ createNotifyHandler
3
+ } from "./chunk-WZ4RNT3A.js";
4
+ export {
5
+ createNotifyHandler
6
+ };
package/dist/react.cjs ADDED
@@ -0,0 +1,6 @@
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true});
2
+
3
+ var _chunkYWA2XDPMcjs = require('./chunk-YWA2XDPM.cjs');
4
+
5
+
6
+ exports.NotificationBell = _chunkYWA2XDPMcjs.NotificationBell;
@@ -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 };
@@ -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,6 @@
1
+ import {
2
+ NotificationBell
3
+ } from "./chunk-WTNWBHMC.js";
4
+ export {
5
+ NotificationBell
6
+ };
@@ -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,2 @@
1
+ export { SQLiteNotificationAdapter, createSQLiteAdapter } from './sqlite'
2
+ export { createPostgresAdapter } from './postgres'
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export { NotificationBell } from './NotificationBell';
2
+ export type { NotificationBellProps, Notification } from './NotificationBell';