@rlse/widget 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/README.md +36 -0
- package/dist/ChangesModal.d.ts +12 -0
- package/dist/ChangesModal.js +177 -0
- package/dist/RlseWidget.d.ts +5 -0
- package/dist/RlseWidget.js +76 -0
- package/dist/TriggerButton.d.ts +10 -0
- package/dist/TriggerButton.js +51 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/storage.d.ts +11 -0
- package/dist/storage.js +80 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.js +12 -0
- package/dist/useWidgetData.d.ts +9 -0
- package/dist/useWidgetData.js +63 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# @rlse/widget
|
|
2
|
+
|
|
3
|
+
React component for embedding rlse.dev release notes in your application.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @rlse/widget
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { RlseWidget } from '@rlse/widget';
|
|
15
|
+
|
|
16
|
+
function App() {
|
|
17
|
+
return (
|
|
18
|
+
<RlseWidget
|
|
19
|
+
orgSlug="your-org-slug"
|
|
20
|
+
trigger="both"
|
|
21
|
+
position="bottom-right"
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
See full documentation at [docs/WIDGET_SETUP.md](../../docs/WIDGET_SETUP.md).
|
|
30
|
+
|
|
31
|
+
## Development
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bun install
|
|
35
|
+
bun run build
|
|
36
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { WidgetConfig, Change } from './types';
|
|
3
|
+
interface ChangesModalProps {
|
|
4
|
+
config: Required<WidgetConfig>;
|
|
5
|
+
changes: Change[];
|
|
6
|
+
isLoading: boolean;
|
|
7
|
+
isOpen: boolean;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
onMarkAsRead: () => void;
|
|
10
|
+
}
|
|
11
|
+
export declare function ChangesModal({ config, changes, isLoading, isOpen, onClose, onMarkAsRead, }: ChangesModalProps): React.ReactElement | null;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect } from 'react';
|
|
3
|
+
import { markAllChangesAsSeen, getUnreadCount } from './storage';
|
|
4
|
+
/**
|
|
5
|
+
* Render a change description for display in the widget.
|
|
6
|
+
* - HTML content (new format, stored after the rich-content migration): passed
|
|
7
|
+
* through directly after stripping event-handler attributes and javascript: URLs.
|
|
8
|
+
* Content is sanitised at the write path before storage.
|
|
9
|
+
* - Markdown content (legacy records): converted via simpleMarkdownToHtml so
|
|
10
|
+
* older records continue to render correctly without a DB migration.
|
|
11
|
+
*/
|
|
12
|
+
function renderDescription(content) {
|
|
13
|
+
if (!content)
|
|
14
|
+
return '';
|
|
15
|
+
if (content.trimStart().startsWith('<')) {
|
|
16
|
+
return content
|
|
17
|
+
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '')
|
|
18
|
+
.replace(/href\s*=\s*"javascript:[^"]*"/gi, '')
|
|
19
|
+
.replace(/href\s*=\s*'javascript:[^']*'/gi, '');
|
|
20
|
+
}
|
|
21
|
+
return simpleMarkdownToHtml(content);
|
|
22
|
+
}
|
|
23
|
+
function simpleMarkdownToHtml(markdown) {
|
|
24
|
+
// Helper to sanitize URLs - only allow safe protocols
|
|
25
|
+
const isSafeUrl = (url) => {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = new URL(url, 'http://example.com');
|
|
28
|
+
const protocol = parsed.protocol.replace(':', '');
|
|
29
|
+
return ['http', 'https', 'mailto', 'tel'].includes(protocol);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Relative URLs are OK
|
|
33
|
+
return (url.startsWith('/') ||
|
|
34
|
+
url.startsWith('./') ||
|
|
35
|
+
url.startsWith('../') ||
|
|
36
|
+
!url.includes(':'));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
return markdown
|
|
40
|
+
.replace(/&/g, '&')
|
|
41
|
+
.replace(/</g, '<')
|
|
42
|
+
.replace(/>/g, '>')
|
|
43
|
+
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
44
|
+
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
|
45
|
+
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
46
|
+
.replace(/_(.+?)_/g, '<em>$1</em>')
|
|
47
|
+
.replace(/`(.+?)`/g, '<code>$1</code>')
|
|
48
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => {
|
|
49
|
+
if (isSafeUrl(url)) {
|
|
50
|
+
// Escape quotes in URL to prevent injection
|
|
51
|
+
const safeUrl = url.replace(/"/g, '%22').replace(/'/g, '%27');
|
|
52
|
+
return `<a href="${safeUrl}" target="_blank" rel="noopener">${text}</a>`;
|
|
53
|
+
}
|
|
54
|
+
return text; // Return plain text for unsafe URLs
|
|
55
|
+
})
|
|
56
|
+
.split('\n\n')
|
|
57
|
+
.map((para) => `<p>${para}</p>`)
|
|
58
|
+
.join('');
|
|
59
|
+
}
|
|
60
|
+
function ChangeCard({ change, showStatus, showDates, }) {
|
|
61
|
+
const date = new Date(change._creationTime).toLocaleDateString('en-US', {
|
|
62
|
+
year: 'numeric',
|
|
63
|
+
month: 'short',
|
|
64
|
+
day: 'numeric',
|
|
65
|
+
});
|
|
66
|
+
const descriptionHtml = renderDescription(change.changeDescription);
|
|
67
|
+
return (_jsxs("article", { style: { padding: '20px 0', borderBottom: '1px solid var(--border)' }, children: [_jsxs("div", { style: {
|
|
68
|
+
display: 'flex',
|
|
69
|
+
alignItems: 'flex-start',
|
|
70
|
+
justifyContent: 'space-between',
|
|
71
|
+
gap: 12,
|
|
72
|
+
marginBottom: 8,
|
|
73
|
+
}, children: [_jsx("h3", { style: { margin: 0, fontSize: 16, fontWeight: 600 }, children: change.changeName }), showStatus && (_jsx("span", { style: {
|
|
74
|
+
fontSize: 12,
|
|
75
|
+
fontWeight: 500,
|
|
76
|
+
padding: '4px 10px',
|
|
77
|
+
borderRadius: 20,
|
|
78
|
+
background: 'rgba(59, 130, 246, 0.1)',
|
|
79
|
+
color: '#3b82f6',
|
|
80
|
+
border: '1px solid #3b82f6',
|
|
81
|
+
whiteSpace: 'nowrap',
|
|
82
|
+
}, children: change.currentStatus }))] }), _jsx("p", { style: {
|
|
83
|
+
margin: '8px 0',
|
|
84
|
+
fontSize: 14,
|
|
85
|
+
color: '#64748b',
|
|
86
|
+
lineHeight: 1.5,
|
|
87
|
+
}, children: change.changeSummary }), _jsx("div", { style: { margin: '12px 0 0', fontSize: 14, lineHeight: 1.6 }, dangerouslySetInnerHTML: { __html: descriptionHtml } }), showDates && (_jsx("div", { style: { marginTop: 12, fontSize: 12, color: '#64748b' }, children: date }))] }));
|
|
88
|
+
}
|
|
89
|
+
export function ChangesModal({ config, changes, isLoading, isOpen, onClose, onMarkAsRead, }) {
|
|
90
|
+
const handleMarkAsRead = useCallback(() => {
|
|
91
|
+
const changeIds = changes.map((c) => c._id);
|
|
92
|
+
markAllChangesAsSeen(config.orgSlug, changeIds);
|
|
93
|
+
onMarkAsRead();
|
|
94
|
+
}, [changes, config.orgSlug, onMarkAsRead]);
|
|
95
|
+
// Close on escape key
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!isOpen)
|
|
98
|
+
return;
|
|
99
|
+
const handleEscape = (e) => {
|
|
100
|
+
if (e.key === 'Escape') {
|
|
101
|
+
onClose();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
document.addEventListener('keydown', handleEscape);
|
|
105
|
+
document.body.style.overflow = 'hidden';
|
|
106
|
+
return () => {
|
|
107
|
+
document.removeEventListener('keydown', handleEscape);
|
|
108
|
+
document.body.style.overflow = '';
|
|
109
|
+
};
|
|
110
|
+
}, [isOpen, onClose]);
|
|
111
|
+
if (!isOpen)
|
|
112
|
+
return null;
|
|
113
|
+
const releaseNotesUrl = config.appSlug
|
|
114
|
+
? `${config.baseUrl}/${config.orgSlug}/${config.appSlug}`
|
|
115
|
+
: `${config.baseUrl}/${config.orgSlug}`;
|
|
116
|
+
const unreadCount = getUnreadCount(config.orgSlug, changes);
|
|
117
|
+
return (_jsx("div", { role: "dialog", "aria-modal": "true", "aria-labelledby": "rlse-widget-title", onClick: (e) => {
|
|
118
|
+
if (e.target === e.currentTarget) {
|
|
119
|
+
onClose();
|
|
120
|
+
}
|
|
121
|
+
}, style: {
|
|
122
|
+
position: 'fixed',
|
|
123
|
+
inset: 0,
|
|
124
|
+
background: 'rgba(0, 0, 0, 0.5)',
|
|
125
|
+
zIndex: 9999,
|
|
126
|
+
display: 'flex',
|
|
127
|
+
alignItems: 'center',
|
|
128
|
+
justifyContent: 'center',
|
|
129
|
+
padding: 20,
|
|
130
|
+
}, children: _jsxs("div", { style: {
|
|
131
|
+
background: 'var(--background, white)',
|
|
132
|
+
borderRadius: 12,
|
|
133
|
+
width: '100%',
|
|
134
|
+
maxWidth: 600,
|
|
135
|
+
maxHeight: '80vh',
|
|
136
|
+
display: 'flex',
|
|
137
|
+
flexDirection: 'column',
|
|
138
|
+
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
|
139
|
+
}, children: [_jsxs("div", { style: {
|
|
140
|
+
display: 'flex',
|
|
141
|
+
alignItems: 'center',
|
|
142
|
+
justifyContent: 'space-between',
|
|
143
|
+
padding: '20px 24px',
|
|
144
|
+
borderBottom: '1px solid var(--border, #e2e8f0)',
|
|
145
|
+
}, children: [_jsx("h2", { id: "rlse-widget-title", style: { margin: 0, fontSize: 18, fontWeight: 600 }, children: config.modalTitle }), _jsx("button", { onClick: onClose, "aria-label": "Close", style: {
|
|
146
|
+
background: 'none',
|
|
147
|
+
border: 'none',
|
|
148
|
+
cursor: 'pointer',
|
|
149
|
+
padding: 8,
|
|
150
|
+
borderRadius: 8,
|
|
151
|
+
display: 'flex',
|
|
152
|
+
alignItems: 'center',
|
|
153
|
+
justifyContent: 'center',
|
|
154
|
+
}, children: _jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M18 6 6 18" }), _jsx("path", { d: "m6 6 12 12" })] }) })] }), _jsx("div", { style: { overflowY: 'auto', padding: '0 24px 24px', flex: 1 }, children: isLoading ? (_jsx("div", { style: { padding: 40, textAlign: 'center' }, children: _jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", style: { animation: 'spin 1s linear infinite' }, children: [_jsx("style", { children: `@keyframes spin{to{transform:rotate(360deg)}}` }), _jsx("path", { d: "M21 12a9 9 0 1 1-6.219-8.56" })] }) })) : changes.length === 0 ? (_jsx("div", { style: { padding: 40, textAlign: 'center', color: '#64748b' }, children: _jsx("p", { children: "No release notes yet. Check back soon!" }) })) : (changes.map((change) => (_jsx(ChangeCard, { change: change, showStatus: config.showStatus, showDates: config.showDates }, change._id)))) }), !isLoading && changes.length > 0 && (_jsxs("div", { style: {
|
|
155
|
+
display: 'flex',
|
|
156
|
+
alignItems: 'center',
|
|
157
|
+
justifyContent: 'space-between',
|
|
158
|
+
padding: '16px 24px',
|
|
159
|
+
borderTop: '1px solid var(--border, #e2e8f0)',
|
|
160
|
+
gap: 12,
|
|
161
|
+
}, children: [_jsx("button", { onClick: handleMarkAsRead, disabled: unreadCount === 0, style: {
|
|
162
|
+
fontSize: 14,
|
|
163
|
+
padding: '8px 16px',
|
|
164
|
+
borderRadius: 8,
|
|
165
|
+
border: 'none',
|
|
166
|
+
background: config.primaryColor || '#3b82f6',
|
|
167
|
+
color: 'white',
|
|
168
|
+
cursor: unreadCount === 0 ? 'default' : 'pointer',
|
|
169
|
+
fontWeight: 500,
|
|
170
|
+
opacity: unreadCount === 0 ? 0.5 : 1,
|
|
171
|
+
}, children: unreadCount === 0 ? 'All caught up' : 'Mark all as read' }), _jsx("a", { href: releaseNotesUrl, target: "_blank", rel: "noopener", style: {
|
|
172
|
+
fontSize: 14,
|
|
173
|
+
color: config.primaryColor || '#3b82f6',
|
|
174
|
+
textDecoration: 'none',
|
|
175
|
+
fontWeight: 500,
|
|
176
|
+
}, children: "View all \u2192" })] }))] }) }));
|
|
177
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
3
|
+
import { DEFAULT_CONFIG } from './types';
|
|
4
|
+
import { useWidgetData } from './useWidgetData';
|
|
5
|
+
import { TriggerButton } from './TriggerButton';
|
|
6
|
+
import { ChangesModal } from './ChangesModal';
|
|
7
|
+
import { setLastVisit, getUnreadCount, shouldAutoShow } from './storage';
|
|
8
|
+
export function RlseWidget(props) {
|
|
9
|
+
const config = { ...DEFAULT_CONFIG, ...props };
|
|
10
|
+
// Hooks must be called before any early return
|
|
11
|
+
const { changes, isLoading, refetch } = useWidgetData(config.orgSlug, config.appSlug, config.limit, config.baseUrl);
|
|
12
|
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
13
|
+
const [hasAutoShown, setHasAutoShown] = useState(false);
|
|
14
|
+
const prevIsModalOpenRef = useRef(isModalOpen);
|
|
15
|
+
const handleOpen = useCallback(() => {
|
|
16
|
+
setIsModalOpen(true);
|
|
17
|
+
}, []);
|
|
18
|
+
const handleClose = useCallback(() => {
|
|
19
|
+
setIsModalOpen(false);
|
|
20
|
+
}, []);
|
|
21
|
+
const handleMarkAsRead = useCallback(() => {
|
|
22
|
+
// Force re-render to update badge
|
|
23
|
+
refetch();
|
|
24
|
+
}, [refetch]);
|
|
25
|
+
// Validate after hooks
|
|
26
|
+
if (!config.orgSlug) {
|
|
27
|
+
console.error('RlseWidget: orgSlug is required');
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
// Auto-show logic
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!config.orgSlug)
|
|
33
|
+
return; // Guard: noop when orgSlug is falsy
|
|
34
|
+
if (hasAutoShown)
|
|
35
|
+
return;
|
|
36
|
+
if (config.trigger === 'manual')
|
|
37
|
+
return;
|
|
38
|
+
if (isLoading || changes.length === 0)
|
|
39
|
+
return;
|
|
40
|
+
const unreadCount = getUnreadCount(config.orgSlug, changes);
|
|
41
|
+
if (unreadCount === 0)
|
|
42
|
+
return;
|
|
43
|
+
const shouldShow = shouldAutoShow(config.orgSlug, config.autoShowAfter);
|
|
44
|
+
if (shouldShow) {
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
setIsModalOpen(true);
|
|
47
|
+
setHasAutoShown(true);
|
|
48
|
+
}, 1000);
|
|
49
|
+
return () => clearTimeout(timer);
|
|
50
|
+
}
|
|
51
|
+
// Update last visit
|
|
52
|
+
setLastVisit(config.orgSlug);
|
|
53
|
+
return undefined;
|
|
54
|
+
}, [
|
|
55
|
+
hasAutoShown,
|
|
56
|
+
config.trigger,
|
|
57
|
+
config.orgSlug,
|
|
58
|
+
config.autoShowAfter,
|
|
59
|
+
isLoading,
|
|
60
|
+
changes,
|
|
61
|
+
]);
|
|
62
|
+
// Update last visit only when modal transitions from open to closed
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!config.orgSlug)
|
|
65
|
+
return; // Guard: noop when orgSlug is falsy
|
|
66
|
+
if (prevIsModalOpenRef.current && !isModalOpen) {
|
|
67
|
+
setLastVisit(config.orgSlug);
|
|
68
|
+
}
|
|
69
|
+
prevIsModalOpenRef.current = isModalOpen;
|
|
70
|
+
}, [isModalOpen, config.orgSlug]);
|
|
71
|
+
// Don't render trigger if trigger is 'auto' only
|
|
72
|
+
if (config.trigger === 'auto') {
|
|
73
|
+
return (_jsx(ChangesModal, { config: config, changes: changes, isLoading: isLoading, isOpen: isModalOpen, onClose: handleClose, onMarkAsRead: handleMarkAsRead }));
|
|
74
|
+
}
|
|
75
|
+
return (_jsxs(_Fragment, { children: [_jsx(TriggerButton, { config: config, changes: changes, onClick: handleOpen }), _jsx(ChangesModal, { config: config, changes: changes, isLoading: isLoading, isOpen: isModalOpen, onClose: handleClose, onMarkAsRead: handleMarkAsRead })] }));
|
|
76
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { WidgetConfig } from './types';
|
|
3
|
+
import type { Change } from './types';
|
|
4
|
+
interface TriggerButtonProps {
|
|
5
|
+
config: Required<WidgetConfig>;
|
|
6
|
+
changes: Change[];
|
|
7
|
+
onClick: () => void;
|
|
8
|
+
}
|
|
9
|
+
export declare function TriggerButton({ config, changes, onClick, }: TriggerButtonProps): React.ReactElement;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { getUnreadCount } from './storage';
|
|
3
|
+
const positionStyles = {
|
|
4
|
+
'bottom-right': { position: 'fixed', bottom: 20, right: 20 },
|
|
5
|
+
'bottom-left': { position: 'fixed', bottom: 20, left: 20 },
|
|
6
|
+
'top-right': { position: 'fixed', top: 20, right: 20 },
|
|
7
|
+
'top-left': { position: 'fixed', top: 20, left: 20 },
|
|
8
|
+
};
|
|
9
|
+
export function TriggerButton({ config, changes, onClick, }) {
|
|
10
|
+
const unreadCount = getUnreadCount(config.orgSlug, changes);
|
|
11
|
+
return (_jsxs("button", { onClick: onClick, "aria-label": config.triggerLabel, style: {
|
|
12
|
+
...positionStyles[config.position],
|
|
13
|
+
zIndex: 9998,
|
|
14
|
+
width: 56,
|
|
15
|
+
height: 56,
|
|
16
|
+
borderRadius: '50%',
|
|
17
|
+
border: 'none',
|
|
18
|
+
cursor: 'pointer',
|
|
19
|
+
display: 'flex',
|
|
20
|
+
alignItems: 'center',
|
|
21
|
+
justifyContent: 'center',
|
|
22
|
+
background: config.primaryColor || '#3b82f6',
|
|
23
|
+
color: 'white',
|
|
24
|
+
boxShadow: '0 4px 14px 0 rgba(59, 130, 246, 0.39)',
|
|
25
|
+
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
|
26
|
+
}, onMouseEnter: (e) => {
|
|
27
|
+
e.currentTarget.style.transform = 'scale(1.05)';
|
|
28
|
+
e.currentTarget.style.boxShadow =
|
|
29
|
+
'0 6px 20px 0 rgba(59, 130, 246, 0.23)';
|
|
30
|
+
}, onMouseLeave: (e) => {
|
|
31
|
+
e.currentTarget.style.transform = 'scale(1)';
|
|
32
|
+
e.currentTarget.style.boxShadow =
|
|
33
|
+
'0 4px 14px 0 rgba(59, 130, 246, 0.39)';
|
|
34
|
+
}, children: [_jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" }), _jsx("path", { d: "M5 3v4" }), _jsx("path", { d: "M19 17v4" }), _jsx("path", { d: "M3 5h4" }), _jsx("path", { d: "M17 19h4" })] }), unreadCount > 0 && (_jsx("span", { style: {
|
|
35
|
+
position: 'absolute',
|
|
36
|
+
top: -2,
|
|
37
|
+
right: -2,
|
|
38
|
+
minWidth: 20,
|
|
39
|
+
height: 20,
|
|
40
|
+
padding: '0 6px',
|
|
41
|
+
borderRadius: 10,
|
|
42
|
+
background: '#ef4444',
|
|
43
|
+
color: 'white',
|
|
44
|
+
fontSize: 12,
|
|
45
|
+
fontWeight: 600,
|
|
46
|
+
display: 'flex',
|
|
47
|
+
alignItems: 'center',
|
|
48
|
+
justifyContent: 'center',
|
|
49
|
+
border: '2px solid white',
|
|
50
|
+
}, children: unreadCount > 99 ? '99+' : unreadCount }))] }));
|
|
51
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { RlseWidget } from './RlseWidget';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function getStorageKey(orgSlug: string): string;
|
|
2
|
+
export declare function getLastVisitKey(orgSlug: string): string;
|
|
3
|
+
export declare function getSeenChangesKey(orgSlug: string): string;
|
|
4
|
+
export declare function getLastVisit(orgSlug: string): number | null;
|
|
5
|
+
export declare function setLastVisit(orgSlug: string): void;
|
|
6
|
+
export declare function getSeenChanges(orgSlug: string): string[];
|
|
7
|
+
export declare function markAllChangesAsSeen(orgSlug: string, changeIds: string[]): void;
|
|
8
|
+
export declare function getUnreadCount(orgSlug: string, changes: {
|
|
9
|
+
_id: string;
|
|
10
|
+
}[]): number;
|
|
11
|
+
export declare function shouldAutoShow(orgSlug: string, autoShowAfter: number): boolean;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const STORAGE_KEY_PREFIX = 'rlse_widget_';
|
|
2
|
+
const LAST_VISIT_KEY_SUFFIX = '_last_visit';
|
|
3
|
+
const SEEN_CHANGES_KEY_SUFFIX = '_seen';
|
|
4
|
+
export function getStorageKey(orgSlug) {
|
|
5
|
+
return `${STORAGE_KEY_PREFIX}${orgSlug}`;
|
|
6
|
+
}
|
|
7
|
+
export function getLastVisitKey(orgSlug) {
|
|
8
|
+
return `${getStorageKey(orgSlug)}${LAST_VISIT_KEY_SUFFIX}`;
|
|
9
|
+
}
|
|
10
|
+
export function getSeenChangesKey(orgSlug) {
|
|
11
|
+
return `${getStorageKey(orgSlug)}${SEEN_CHANGES_KEY_SUFFIX}`;
|
|
12
|
+
}
|
|
13
|
+
export function getLastVisit(orgSlug) {
|
|
14
|
+
if (typeof window === 'undefined')
|
|
15
|
+
return null;
|
|
16
|
+
try {
|
|
17
|
+
const value = localStorage.getItem(getLastVisitKey(orgSlug));
|
|
18
|
+
if (!value)
|
|
19
|
+
return null;
|
|
20
|
+
const parsed = parseInt(value, 10);
|
|
21
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function setLastVisit(orgSlug) {
|
|
28
|
+
if (typeof window === 'undefined')
|
|
29
|
+
return;
|
|
30
|
+
try {
|
|
31
|
+
localStorage.setItem(getLastVisitKey(orgSlug), Date.now().toString());
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Ignore storage errors
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function getSeenChanges(orgSlug) {
|
|
38
|
+
if (typeof window === 'undefined')
|
|
39
|
+
return [];
|
|
40
|
+
try {
|
|
41
|
+
const value = localStorage.getItem(getSeenChangesKey(orgSlug));
|
|
42
|
+
if (!value)
|
|
43
|
+
return [];
|
|
44
|
+
const parsed = JSON.parse(value);
|
|
45
|
+
// Validate that parsed is an array of strings
|
|
46
|
+
if (!Array.isArray(parsed) ||
|
|
47
|
+
!parsed.every((item) => typeof item === 'string')) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function markAllChangesAsSeen(orgSlug, changeIds) {
|
|
57
|
+
if (typeof window === 'undefined')
|
|
58
|
+
return;
|
|
59
|
+
try {
|
|
60
|
+
localStorage.setItem(getSeenChangesKey(orgSlug), JSON.stringify(changeIds));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Ignore storage errors
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function getUnreadCount(orgSlug, changes) {
|
|
67
|
+
if (typeof window === 'undefined')
|
|
68
|
+
return 0;
|
|
69
|
+
const seen = getSeenChanges(orgSlug);
|
|
70
|
+
return changes.filter((c) => !seen.includes(c._id)).length;
|
|
71
|
+
}
|
|
72
|
+
export function shouldAutoShow(orgSlug, autoShowAfter) {
|
|
73
|
+
if (typeof window === 'undefined')
|
|
74
|
+
return false;
|
|
75
|
+
const lastVisit = getLastVisit(orgSlug);
|
|
76
|
+
if (!lastVisit)
|
|
77
|
+
return true; // First visit
|
|
78
|
+
const daysSinceVisit = (Date.now() - lastVisit) / (1000 * 60 * 60 * 24);
|
|
79
|
+
return daysSinceVisit >= autoShowAfter;
|
|
80
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface WidgetConfig {
|
|
2
|
+
/** Organization slug (required) */
|
|
3
|
+
orgSlug: string;
|
|
4
|
+
/** Optional app slug to filter changes */
|
|
5
|
+
appSlug?: string;
|
|
6
|
+
/** Trigger behavior: 'auto', 'manual', or 'both' */
|
|
7
|
+
trigger?: 'auto' | 'manual' | 'both';
|
|
8
|
+
/** Days since last visit to auto-show (for 'auto' or 'both') */
|
|
9
|
+
autoShowAfter?: number;
|
|
10
|
+
/** Maximum changes to display */
|
|
11
|
+
limit?: number;
|
|
12
|
+
/** Show status badges */
|
|
13
|
+
showStatus?: boolean;
|
|
14
|
+
/** Show creation dates */
|
|
15
|
+
showDates?: boolean;
|
|
16
|
+
/** Button position for manual trigger */
|
|
17
|
+
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
18
|
+
/** Theme: 'light', 'dark', or 'auto' */
|
|
19
|
+
theme?: 'light' | 'dark' | 'auto';
|
|
20
|
+
/** Primary accent color (hex) */
|
|
21
|
+
primaryColor?: string;
|
|
22
|
+
/** Button text */
|
|
23
|
+
triggerLabel?: string;
|
|
24
|
+
/** Modal title */
|
|
25
|
+
modalTitle?: string;
|
|
26
|
+
/** Base URL for API (optional, defaults to https://rlse.dev) */
|
|
27
|
+
baseUrl?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface Change {
|
|
30
|
+
_id: string;
|
|
31
|
+
_creationTime: number;
|
|
32
|
+
changeName: string;
|
|
33
|
+
changeSummary: string;
|
|
34
|
+
changeDescription: string;
|
|
35
|
+
currentStatus: string;
|
|
36
|
+
appName?: string;
|
|
37
|
+
appSlug?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface WidgetChangesResponse {
|
|
40
|
+
org: {
|
|
41
|
+
orgName: string;
|
|
42
|
+
orgSlug: string;
|
|
43
|
+
};
|
|
44
|
+
app?: {
|
|
45
|
+
appName: string;
|
|
46
|
+
appSlug: string;
|
|
47
|
+
};
|
|
48
|
+
changes: Change[];
|
|
49
|
+
meta: {
|
|
50
|
+
total: number;
|
|
51
|
+
limit: number;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export declare const DEFAULT_CONFIG: Partial<WidgetConfig>;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const DEFAULT_CONFIG = {
|
|
2
|
+
trigger: 'manual',
|
|
3
|
+
limit: 10,
|
|
4
|
+
showStatus: true,
|
|
5
|
+
showDates: true,
|
|
6
|
+
position: 'bottom-right',
|
|
7
|
+
theme: 'auto',
|
|
8
|
+
triggerLabel: "What's New",
|
|
9
|
+
modalTitle: 'Release Notes',
|
|
10
|
+
baseUrl: 'https://rlse.dev',
|
|
11
|
+
autoShowAfter: 7,
|
|
12
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Change } from './types';
|
|
2
|
+
interface UseWidgetDataResult {
|
|
3
|
+
changes: Change[];
|
|
4
|
+
isLoading: boolean;
|
|
5
|
+
error: Error | null;
|
|
6
|
+
refetch: () => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export declare function useWidgetData(orgSlug: string, appSlug: string | undefined, limit: number, baseUrl: string): UseWidgetDataResult;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
export function useWidgetData(orgSlug, appSlug, limit, baseUrl) {
|
|
3
|
+
const [changes, setChanges] = useState([]);
|
|
4
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
5
|
+
const [error, setError] = useState(null);
|
|
6
|
+
const abortControllerRef = useRef(null);
|
|
7
|
+
const fetchChanges = useCallback(async () => {
|
|
8
|
+
// Cancel any in-flight request
|
|
9
|
+
if (abortControllerRef.current) {
|
|
10
|
+
abortControllerRef.current.abort();
|
|
11
|
+
}
|
|
12
|
+
abortControllerRef.current = new AbortController();
|
|
13
|
+
setIsLoading(true);
|
|
14
|
+
setError(null);
|
|
15
|
+
try {
|
|
16
|
+
// Validate baseUrl
|
|
17
|
+
if (!baseUrl || typeof baseUrl !== 'string') {
|
|
18
|
+
throw new Error(`Invalid baseUrl: ${baseUrl}`);
|
|
19
|
+
}
|
|
20
|
+
let url;
|
|
21
|
+
try {
|
|
22
|
+
url = new URL(`${baseUrl}/api/widget/changes`);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new Error(`Invalid baseUrl for widget API: ${baseUrl}`);
|
|
26
|
+
}
|
|
27
|
+
url.searchParams.set('orgSlug', orgSlug);
|
|
28
|
+
if (appSlug) {
|
|
29
|
+
url.searchParams.set('appSlug', appSlug);
|
|
30
|
+
}
|
|
31
|
+
url.searchParams.set('limit', limit.toString());
|
|
32
|
+
const response = await fetch(url.toString(), {
|
|
33
|
+
signal: abortControllerRef.current.signal,
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
throw new Error(`Failed to fetch changes: ${response.status}`);
|
|
37
|
+
}
|
|
38
|
+
const data = await response.json();
|
|
39
|
+
setChanges(data.changes);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
// Don't update state if request was aborted
|
|
43
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
setError(err instanceof Error ? err : new Error('Unknown error'));
|
|
47
|
+
setChanges([]);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
setIsLoading(false);
|
|
51
|
+
}
|
|
52
|
+
}, [orgSlug, appSlug, limit, baseUrl]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
fetchChanges();
|
|
55
|
+
return () => {
|
|
56
|
+
// Cleanup: abort in-flight request on unmount
|
|
57
|
+
if (abortControllerRef.current) {
|
|
58
|
+
abortControllerRef.current.abort();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}, [fetchChanges]);
|
|
62
|
+
return { changes, isLoading, error, refetch: fetchChanges };
|
|
63
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rlse/widget",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React release notes widget for rlse.dev",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"dev": "tsc --watch",
|
|
23
|
+
"test": "bun test src",
|
|
24
|
+
"prepublishOnly": "bun run build"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
28
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/react": "^19.0.0",
|
|
32
|
+
"@types/react-dom": "^19.0.0",
|
|
33
|
+
"bun-types": "latest",
|
|
34
|
+
"typescript": "^5.0.0"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"release-notes",
|
|
38
|
+
"changelog",
|
|
39
|
+
"widget",
|
|
40
|
+
"react",
|
|
41
|
+
"embed"
|
|
42
|
+
],
|
|
43
|
+
"license": "MIT"
|
|
44
|
+
}
|