@ima-jin/ui 1.0.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.
@@ -0,0 +1,144 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
4
+ import { useNotifications } from './notification-provider';
5
+
6
+ function relativeTime(dateStr: string): string {
7
+ const diff = Date.now() - new Date(dateStr).getTime();
8
+ const minutes = Math.floor(diff / 60_000);
9
+ if (minutes < 1) return 'just now';
10
+ if (minutes < 60) return `${minutes}m ago`;
11
+ const hours = Math.floor(minutes / 60);
12
+ if (hours < 24) return `${hours}h ago`;
13
+ const days = Math.floor(hours / 24);
14
+ if (days === 1) return 'Yesterday';
15
+ return `${days}d ago`;
16
+ }
17
+
18
+ function BellIcon({ className }: { className?: string }) {
19
+ return (
20
+ <svg
21
+ className={className}
22
+ xmlns="http://www.w3.org/2000/svg"
23
+ viewBox="0 0 24 24"
24
+ fill="none"
25
+ stroke="currentColor"
26
+ strokeWidth={2}
27
+ strokeLinecap="round"
28
+ strokeLinejoin="round"
29
+ aria-hidden="true"
30
+ >
31
+ <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
32
+ <path d="M13.73 21a2 2 0 0 1-3.46 0" />
33
+ </svg>
34
+ );
35
+ }
36
+
37
+ export function NotificationBell() {
38
+ const { unreadCount, notifications, markAsRead, markAllAsRead, refresh } = useNotifications();
39
+ const [open, setOpen] = useState(false);
40
+ const [loading, setLoading] = useState(false);
41
+ const panelRef = useRef<HTMLDivElement>(null);
42
+
43
+ const handleOpen = useCallback(async () => {
44
+ if (!open) {
45
+ setOpen(true);
46
+ setLoading(true);
47
+ await refresh();
48
+ setLoading(false);
49
+ } else {
50
+ setOpen(false);
51
+ }
52
+ }, [open, refresh]);
53
+
54
+ useEffect(() => {
55
+ if (!open) return;
56
+ function handleClickOutside(e: MouseEvent) {
57
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
58
+ setOpen(false);
59
+ }
60
+ }
61
+ function handleKeyDown(e: KeyboardEvent) {
62
+ if (e.key === 'Escape') setOpen(false);
63
+ }
64
+ document.addEventListener('mousedown', handleClickOutside);
65
+ document.addEventListener('keydown', handleKeyDown);
66
+ return () => {
67
+ document.removeEventListener('mousedown', handleClickOutside);
68
+ document.removeEventListener('keydown', handleKeyDown);
69
+ };
70
+ }, [open]);
71
+
72
+ const hasUnread = notifications.some(n => !n.read);
73
+
74
+ return (
75
+ <div className="relative" ref={panelRef}>
76
+ <button
77
+ onClick={handleOpen}
78
+ className={`relative p-2 rounded-lg transition ${
79
+ open ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-100 dark:hover:bg-gray-800'
80
+ }`}
81
+ title="Notifications"
82
+ aria-label={`Notifications${unreadCount > 0 ? `, ${unreadCount} unread` : ''}`}
83
+ aria-expanded={open}
84
+ >
85
+ <BellIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
86
+ {unreadCount > 0 && (
87
+ <span className="absolute -top-0.5 -right-0.5 bg-red-500 text-white text-[10px] font-bold rounded-full min-w-[1.1rem] h-[1.1rem] flex items-center justify-center px-1 leading-none pointer-events-none">
88
+ {unreadCount > 99 ? '99+' : unreadCount}
89
+ </span>
90
+ )}
91
+ </button>
92
+
93
+ {open && (
94
+ <div className="absolute right-0 mt-2 w-80 bg-gray-900 border border-gray-700 rounded-lg shadow-lg z-50 flex flex-col max-h-[24rem]">
95
+ {/* Header */}
96
+ <div className="flex items-center justify-between px-4 py-3 border-b border-gray-700 shrink-0">
97
+ <span className="text-sm font-semibold text-white">Notifications</span>
98
+ {hasUnread && (
99
+ <button
100
+ onClick={() => markAllAsRead()}
101
+ className="text-xs text-blue-400 hover:text-blue-300 transition"
102
+ >
103
+ Mark all as read
104
+ </button>
105
+ )}
106
+ </div>
107
+
108
+ {/* Body */}
109
+ <div className="overflow-y-auto flex-1">
110
+ {loading ? (
111
+ <div className="px-4 py-6 text-center text-sm text-gray-400">Loading…</div>
112
+ ) : notifications.length === 0 ? (
113
+ <div className="px-4 py-6 text-center text-sm text-gray-400">No notifications yet</div>
114
+ ) : (
115
+ notifications.map(notification => (
116
+ <button
117
+ key={notification.id}
118
+ onClick={() => markAsRead(notification.id)}
119
+ className={`w-full text-left px-4 py-3 border-b border-gray-800 last:border-0 hover:bg-gray-800 transition flex items-start gap-3 ${
120
+ !notification.read ? 'bg-gray-800/50' : ''
121
+ }`}
122
+ >
123
+ {/* Unread dot — space always reserved so text aligns */}
124
+ <span className="shrink-0 w-2 h-2 rounded-full mt-1.5 flex items-center justify-center">
125
+ {!notification.read && (
126
+ <span className="block w-2 h-2 rounded-full bg-blue-500" aria-hidden="true" />
127
+ )}
128
+ </span>
129
+ <div className="flex-1 min-w-0">
130
+ <p className="text-sm font-medium text-white truncate">{notification.title}</p>
131
+ {notification.body && (
132
+ <p className="text-xs text-gray-400 mt-0.5 line-clamp-2">{notification.body}</p>
133
+ )}
134
+ <p className="text-xs text-gray-500 mt-1">{relativeTime(notification.createdAt)}</p>
135
+ </div>
136
+ </button>
137
+ ))
138
+ )}
139
+ </div>
140
+ </div>
141
+ )}
142
+ </div>
143
+ );
144
+ }
@@ -0,0 +1,134 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
4
+ import { useToast } from './toast';
5
+
6
+ export interface Notification {
7
+ id: string;
8
+ title: string;
9
+ body?: string;
10
+ urgency?: 'normal' | 'urgent';
11
+ read: boolean;
12
+ createdAt: string;
13
+ }
14
+
15
+ export interface NotificationContextValue {
16
+ unreadCount: number;
17
+ notifications: Notification[];
18
+ markAsRead: (id: string) => Promise<void>;
19
+ markAllAsRead: () => Promise<void>;
20
+ refresh: () => Promise<void>;
21
+ }
22
+
23
+ const NotificationContext = createContext<NotificationContextValue | null>(null);
24
+
25
+ const emptyValue: NotificationContextValue = {
26
+ unreadCount: 0,
27
+ notifications: [],
28
+ markAsRead: async () => {},
29
+ markAllAsRead: async () => {},
30
+ refresh: async () => {},
31
+ };
32
+
33
+ export function NotificationProvider({ children }: { children: React.ReactNode }) {
34
+ const notifyUrl = process.env.NEXT_PUBLIC_NOTIFY_URL;
35
+ const [unreadCount, setUnreadCount] = useState(0);
36
+ const [notifications, setNotifications] = useState<Notification[]>([]);
37
+ const hasWarnedRef = useRef(false);
38
+ const lastCountRef = useRef(-1); // -1 = not yet initialised, skip toast on first load
39
+ const lastNewIdRef = useRef<string | null>(null);
40
+ const { toast } = useToast();
41
+
42
+ // Keep toast functions in a ref so fetchAndMaybeToast doesn't need them as deps
43
+ const toastRef = useRef(toast);
44
+ toastRef.current = toast;
45
+
46
+ const fetchAndMaybeToast = useCallback(async () => {
47
+ if (!notifyUrl) return;
48
+ try {
49
+ const res = await fetch(`${notifyUrl}/api/notifications/unread`, { credentials: 'include' });
50
+ if (!res.ok) return;
51
+ const data = await res.json();
52
+ const count: number = data.count ?? 0;
53
+
54
+ if (lastCountRef.current >= 0 && count > lastCountRef.current) {
55
+ // New notifications arrived — fetch the latest one and toast
56
+ try {
57
+ const listRes = await fetch(`${notifyUrl}/api/notifications?limit=1`, { credentials: 'include' });
58
+ if (listRes.ok) {
59
+ const listData = await listRes.json();
60
+ const latest: Notification | undefined = listData.notifications?.[0] ?? listData[0];
61
+ if (latest && latest.id !== lastNewIdRef.current) {
62
+ lastNewIdRef.current = latest.id;
63
+ if (latest.urgency === 'urgent') {
64
+ toastRef.current.warning(latest.title);
65
+ } else {
66
+ toastRef.current.info(latest.title);
67
+ }
68
+ }
69
+ }
70
+ } catch {}
71
+ }
72
+
73
+ lastCountRef.current = count;
74
+ setUnreadCount(count);
75
+ } catch {
76
+ if (!hasWarnedRef.current) {
77
+ console.warn('[notifications] notify service unavailable — bell will show 0 unread');
78
+ hasWarnedRef.current = true;
79
+ }
80
+ }
81
+ }, [notifyUrl]);
82
+
83
+ useEffect(() => {
84
+ if (!notifyUrl) return;
85
+ fetchAndMaybeToast();
86
+ const interval = setInterval(fetchAndMaybeToast, 30_000);
87
+ return () => clearInterval(interval);
88
+ }, [notifyUrl, fetchAndMaybeToast]);
89
+
90
+ const refresh = useCallback(async () => {
91
+ if (!notifyUrl) return;
92
+ try {
93
+ const res = await fetch(`${notifyUrl}/api/notifications?limit=20`, { credentials: 'include' });
94
+ if (!res.ok) return;
95
+ const data = await res.json();
96
+ setNotifications(data.notifications ?? data ?? []);
97
+ } catch {}
98
+ }, [notifyUrl]);
99
+
100
+ const markAsRead = useCallback(async (id: string) => {
101
+ if (!notifyUrl) return;
102
+ try {
103
+ await fetch(`${notifyUrl}/api/notifications/${id}/read`, {
104
+ method: 'POST',
105
+ credentials: 'include',
106
+ });
107
+ setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
108
+ setUnreadCount(prev => Math.max(0, prev - 1));
109
+ } catch {}
110
+ }, [notifyUrl]);
111
+
112
+ const markAllAsRead = useCallback(async () => {
113
+ if (!notifyUrl) return;
114
+ try {
115
+ await fetch(`${notifyUrl}/api/notifications/read-all`, {
116
+ method: 'POST',
117
+ credentials: 'include',
118
+ });
119
+ setNotifications(prev => prev.map(n => ({ ...n, read: true })));
120
+ setUnreadCount(0);
121
+ lastCountRef.current = 0;
122
+ } catch {}
123
+ }, [notifyUrl]);
124
+
125
+ return (
126
+ <NotificationContext.Provider value={{ unreadCount, notifications, markAsRead, markAllAsRead, refresh }}>
127
+ {children}
128
+ </NotificationContext.Provider>
129
+ );
130
+ }
131
+
132
+ export function useNotifications(): NotificationContextValue {
133
+ return useContext(NotificationContext) ?? emptyValue;
134
+ }
@@ -0,0 +1,10 @@
1
+ export const themeInitScript = `
2
+ (function() {
3
+ var theme = localStorage.getItem('theme');
4
+ if (theme === 'light') {
5
+ document.documentElement.classList.remove('dark');
6
+ } else {
7
+ document.documentElement.classList.add('dark');
8
+ }
9
+ })()
10
+ `;
package/src/toast.tsx ADDED
@@ -0,0 +1,147 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext, useReducer, useCallback, useEffect } from 'react';
4
+
5
+ export type ToastType = 'success' | 'error' | 'warning' | 'info';
6
+
7
+ interface Toast {
8
+ id: string;
9
+ type: ToastType;
10
+ message: string;
11
+ sticky: boolean;
12
+ }
13
+
14
+ type Action =
15
+ | { type: 'ADD'; toast: Toast }
16
+ | { type: 'REMOVE'; id: string };
17
+
18
+ function reducer(state: Toast[], action: Action): Toast[] {
19
+ switch (action.type) {
20
+ case 'ADD':
21
+ return [...state, action.toast].slice(-5);
22
+ case 'REMOVE':
23
+ return state.filter((t) => t.id !== action.id);
24
+ default:
25
+ return state;
26
+ }
27
+ }
28
+
29
+ interface ToastContextValue {
30
+ toast: {
31
+ success: (message: string) => void;
32
+ error: (message: string) => void;
33
+ warning: (message: string) => void;
34
+ info: (message: string) => void;
35
+ };
36
+ }
37
+
38
+ const ToastContext = createContext<ToastContextValue | null>(null);
39
+
40
+ const BORDER_COLORS: Record<ToastType, string> = {
41
+ success: 'border-l-green-500',
42
+ error: 'border-l-red-500',
43
+ warning: 'border-l-amber-500',
44
+ info: 'border-l-blue-500',
45
+ };
46
+
47
+ const DURATIONS: Record<ToastType, number> = {
48
+ success: 4000,
49
+ error: 0,
50
+ warning: 6000,
51
+ info: 4000,
52
+ };
53
+
54
+ function AutoDismiss({
55
+ id,
56
+ duration,
57
+ onDismiss,
58
+ }: {
59
+ id: string;
60
+ duration: number;
61
+ onDismiss: (id: string) => void;
62
+ }) {
63
+ useEffect(() => {
64
+ const timer = setTimeout(() => onDismiss(id), duration);
65
+ return () => clearTimeout(timer);
66
+ }, [id, duration, onDismiss]);
67
+ return null;
68
+ }
69
+
70
+ function ToastItem({
71
+ toast,
72
+ onDismiss,
73
+ }: {
74
+ toast: Toast;
75
+ onDismiss: () => void;
76
+ }) {
77
+ return (
78
+ <div
79
+ className={`flex items-start gap-3 bg-gray-900 text-white px-4 py-3 rounded-lg shadow-lg border-l-4 ${BORDER_COLORS[toast.type]} min-w-[280px] max-w-sm`}
80
+ style={{ animation: 'toastSlideIn 0.2s ease-out' }}
81
+ >
82
+ <p className="flex-1 text-sm">{toast.message}</p>
83
+ <button
84
+ onClick={onDismiss}
85
+ className="text-gray-400 hover:text-white transition-colors shrink-0 text-lg leading-none"
86
+ aria-label="Dismiss"
87
+ >
88
+ ×
89
+ </button>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ export function ToastProvider({ children }: { children: React.ReactNode }) {
95
+ const [toasts, dispatch] = useReducer(reducer, []);
96
+
97
+ const dismiss = useCallback((id: string) => {
98
+ dispatch({ type: 'REMOVE', id });
99
+ }, []);
100
+
101
+ const add = useCallback((type: ToastType, message: string) => {
102
+ const id =
103
+ typeof crypto !== 'undefined' && crypto.randomUUID
104
+ ? crypto.randomUUID()
105
+ : String(Date.now() + Math.random());
106
+ const sticky = type === 'error';
107
+ dispatch({ type: 'ADD', toast: { id, type, message, sticky } });
108
+ }, []);
109
+
110
+ const toast = {
111
+ success: (message: string) => add('success', message),
112
+ error: (message: string) => add('error', message),
113
+ warning: (message: string) => add('warning', message),
114
+ info: (message: string) => add('info', message),
115
+ };
116
+
117
+ return (
118
+ <ToastContext.Provider value={{ toast }}>
119
+ {children}
120
+ <div
121
+ className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 items-end"
122
+ aria-live="polite"
123
+ >
124
+ <style>{`
125
+ @keyframes toastSlideIn {
126
+ from { transform: translateX(calc(100% + 1rem)); opacity: 0; }
127
+ to { transform: translateX(0); opacity: 1; }
128
+ }
129
+ `}</style>
130
+ {toasts.map((t) => (
131
+ <React.Fragment key={t.id}>
132
+ {!t.sticky && DURATIONS[t.type] > 0 && (
133
+ <AutoDismiss id={t.id} duration={DURATIONS[t.type]} onDismiss={dismiss} />
134
+ )}
135
+ <ToastItem toast={t} onDismiss={() => dismiss(t.id)} />
136
+ </React.Fragment>
137
+ ))}
138
+ </div>
139
+ </ToastContext.Provider>
140
+ );
141
+ }
142
+
143
+ export function useToast(): ToastContextValue {
144
+ const ctx = useContext(ToastContext);
145
+ if (!ctx) throw new Error('useToast must be used within a ToastProvider');
146
+ return ctx;
147
+ }
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { getActingAs, setActingAs } from './acting-as';
5
+
6
+ export interface GroupIdentity {
7
+ groupDid: string;
8
+ role: string;
9
+ scope: string;
10
+ name: string | null;
11
+ handle: string | null;
12
+ }
13
+
14
+ export interface IdentityConfig {
15
+ enabledServices: string[];
16
+ landingService: string | null;
17
+ }
18
+
19
+ export function useIdentities(authUrl: string | null, profileUrl?: string | null): {
20
+ identities: GroupIdentity[];
21
+ loading: boolean;
22
+ activeIdentity: string | null;
23
+ activeConfig: IdentityConfig | null;
24
+ setActiveIdentity: (did: string | null) => void;
25
+ } {
26
+ const [identities, setIdentities] = useState<GroupIdentity[]>([]);
27
+ const [loading, setLoading] = useState(false);
28
+ const [activeIdentity, setActiveIdentityState] = useState<string | null>(null);
29
+ const [activeConfig, setActiveConfig] = useState<IdentityConfig | null>(null);
30
+
31
+ useEffect(() => {
32
+ setActiveIdentityState(getActingAs());
33
+ }, []);
34
+
35
+ useEffect(() => {
36
+ if (!authUrl) return;
37
+ setLoading(true);
38
+ fetch(`${authUrl}/api/groups`, { credentials: 'include' })
39
+ .then((r) => r.ok ? r.json() : null)
40
+ .then((data) => {
41
+ if (Array.isArray(data)) setIdentities(data);
42
+ else if (data?.groups) setIdentities(data.groups);
43
+ })
44
+ .catch(() => {})
45
+ .finally(() => setLoading(false));
46
+ }, [authUrl]);
47
+
48
+ useEffect(() => {
49
+ const configBase = profileUrl;
50
+ if (!configBase || !activeIdentity) {
51
+ setActiveConfig(null);
52
+ return;
53
+ }
54
+ fetch(`${configBase}/api/forest/${encodeURIComponent(activeIdentity)}/config/public`)
55
+ .then((r) => r.ok ? r.json() : null)
56
+ .then((data) => {
57
+ if (data) setActiveConfig(data as IdentityConfig);
58
+ })
59
+ .catch(() => {});
60
+ }, [authUrl, profileUrl, activeIdentity]);
61
+
62
+ function setActiveIdentity(did: string | null) {
63
+ setActingAs(did);
64
+ setActiveIdentityState(did);
65
+ window.location.reload();
66
+ }
67
+
68
+ return { identities, loading, activeIdentity, activeConfig, setActiveIdentity };
69
+ }