@rlse/widget 0.1.5 → 0.2.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.
@@ -1,62 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect } from 'react';
3
+ import { renderDescription, getReleaseNotesUrl } from '@rlse/widget-core';
3
4
  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);
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, '&amp;')
41
- .replace(/</g, '&lt;')
42
- .replace(/>/g, '&gt;')
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
5
  function ChangeCard({ change, showStatus, showDates, }) {
61
6
  const date = new Date(change._creationTime).toLocaleDateString('en-US', {
62
7
  year: 'numeric',
@@ -110,18 +55,7 @@ export function ChangesModal({ config, changes, isLoading, isOpen, onClose, onMa
110
55
  }, [isOpen, onClose]);
111
56
  if (!isOpen)
112
57
  return null;
113
- const releaseNotesUrl = (() => {
114
- try {
115
- const parsed = new URL(config.baseUrl);
116
- const base = `${parsed.protocol}//${config.orgSlug}.${parsed.host}`;
117
- return config.appSlug ? `${base}/${config.appSlug}` : base;
118
- }
119
- catch {
120
- return config.appSlug
121
- ? `${config.baseUrl}/${config.orgSlug}/${config.appSlug}`
122
- : `${config.baseUrl}/${config.orgSlug}`;
123
- }
124
- })();
58
+ const releaseNotesUrl = getReleaseNotesUrl(config.baseUrl, config.orgSlug, config.appSlug);
125
59
  const unreadCount = getUnreadCount(config.orgSlug, changes);
126
60
  return (_jsx("div", { role: "dialog", "aria-modal": "true", "aria-labelledby": "rlse-widget-title", onClick: (e) => {
127
61
  if (e.target === e.currentTarget) {
@@ -0,0 +1,11 @@
1
+ import React from 'react';
2
+ import type { WidgetConfig } from './types';
3
+ export interface RlseWidgetEmbedProps extends WidgetConfig {
4
+ /** Container style - pass CSS properties for the container */
5
+ containerStyle?: React.CSSProperties;
6
+ /** Show the header with title */
7
+ showHeader?: boolean;
8
+ /** Show the footer with "mark as read" and "view all" */
9
+ showFooter?: boolean;
10
+ }
11
+ export declare function RlseWidgetEmbed(props: RlseWidgetEmbedProps): React.ReactElement | null;
@@ -0,0 +1,98 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback } from 'react';
3
+ import { renderDescription, formatChangeDate, getReleaseNotesUrl } from '@rlse/widget-core';
4
+ import { DEFAULT_CONFIG } from './types';
5
+ import { useWidgetData } from './useWidgetData';
6
+ import { markAllChangesAsSeen, getUnreadCount } from './storage';
7
+ export function RlseWidgetEmbed(props) {
8
+ const config = { ...DEFAULT_CONFIG, ...props };
9
+ const { containerStyle, showHeader = true, showFooter = true } = props;
10
+ const { changes, isLoading } = useWidgetData(config.orgSlug, config.appSlug, config.limit, config.baseUrl);
11
+ const handleMarkAsRead = useCallback(() => {
12
+ const changeIds = changes.map((c) => c._id);
13
+ markAllChangesAsSeen(config.orgSlug, changeIds);
14
+ // Trigger re-render
15
+ window.dispatchEvent(new Event('storage'));
16
+ }, [changes, config.orgSlug]);
17
+ const unreadCount = getUnreadCount(config.orgSlug, changes);
18
+ const releaseNotesUrl = getReleaseNotesUrl(config.baseUrl, config.orgSlug, config.appSlug);
19
+ const defaultContainerStyle = {
20
+ background: 'var(--background, white)',
21
+ borderRadius: 12,
22
+ border: '1px solid var(--border, #e2e8f0)',
23
+ display: 'flex',
24
+ flexDirection: 'column',
25
+ maxHeight: '100%',
26
+ overflow: 'hidden',
27
+ };
28
+ const headerStyle = {
29
+ display: 'flex',
30
+ alignItems: 'center',
31
+ justifyContent: 'space-between',
32
+ padding: '16px 20px',
33
+ borderBottom: '1px solid var(--border, #e2e8f0)',
34
+ };
35
+ const titleStyle = {
36
+ margin: 0,
37
+ fontSize: 16,
38
+ fontWeight: 600,
39
+ };
40
+ const contentStyle = {
41
+ overflowY: 'auto',
42
+ padding: '0 20px',
43
+ flex: 1,
44
+ };
45
+ const footerStyle = {
46
+ display: 'flex',
47
+ alignItems: 'center',
48
+ justifyContent: 'space-between',
49
+ padding: '12px 20px',
50
+ borderTop: '1px solid var(--border, #e2e8f0)',
51
+ gap: 12,
52
+ };
53
+ return (_jsxs("div", { style: { ...defaultContainerStyle, ...containerStyle }, children: [showHeader && (_jsxs("div", { style: headerStyle, children: [_jsx("h2", { style: titleStyle, children: config.modalTitle }), unreadCount > 0 && (_jsx("span", { style: {
54
+ fontSize: 12,
55
+ fontWeight: 500,
56
+ padding: '2px 8px',
57
+ borderRadius: 10,
58
+ background: config.primaryColor || '#3b82f6',
59
+ color: 'white',
60
+ }, children: unreadCount }))] })), _jsx("div", { style: contentStyle, 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) => (_jsxs("article", { style: { padding: '16px 0', borderBottom: '1px solid var(--border, #e2e8f0)' }, children: [_jsxs("div", { style: {
61
+ display: 'flex',
62
+ alignItems: 'flex-start',
63
+ justifyContent: 'space-between',
64
+ gap: 12,
65
+ marginBottom: 4,
66
+ }, children: [_jsx("h3", { style: { margin: 0, fontSize: 14, fontWeight: 600 }, children: change.changeName }), config.showStatus && (_jsx("span", { style: {
67
+ fontSize: 11,
68
+ fontWeight: 500,
69
+ padding: '2px 8px',
70
+ borderRadius: 12,
71
+ background: 'rgba(59, 130, 246, 0.1)',
72
+ color: config.primaryColor || '#3b82f6',
73
+ border: `1px solid ${config.primaryColor || '#3b82f6'}`,
74
+ whiteSpace: 'nowrap',
75
+ }, children: change.currentStatus }))] }), _jsx("p", { style: {
76
+ margin: '4px 0',
77
+ fontSize: 13,
78
+ color: '#64748b',
79
+ lineHeight: 1.4,
80
+ }, children: change.changeSummary }), _jsx("div", { style: { margin: '8px 0 0', fontSize: 13, lineHeight: 1.5 }, dangerouslySetInnerHTML: {
81
+ __html: renderDescription(change.changeDescription),
82
+ } }), config.showDates && (_jsx("div", { style: { marginTop: 8, fontSize: 11, color: '#64748b' }, children: formatChangeDate(change._creationTime) }))] }, change._id)))) }), showFooter && !isLoading && changes.length > 0 && (_jsxs("div", { style: footerStyle, children: [_jsx("button", { onClick: handleMarkAsRead, disabled: unreadCount === 0, style: {
83
+ fontSize: 13,
84
+ padding: '6px 12px',
85
+ borderRadius: 6,
86
+ border: 'none',
87
+ background: config.primaryColor || '#3b82f6',
88
+ color: 'white',
89
+ cursor: unreadCount === 0 ? 'default' : 'pointer',
90
+ fontWeight: 500,
91
+ opacity: unreadCount === 0 ? 0.5 : 1,
92
+ }, children: unreadCount === 0 ? 'All caught up' : 'Mark all as read' }), _jsx("a", { href: releaseNotesUrl, target: "_blank", rel: "noopener", style: {
93
+ fontSize: 13,
94
+ color: config.primaryColor || '#3b82f6',
95
+ textDecoration: 'none',
96
+ fontWeight: 500,
97
+ }, children: "View all \u2192" })] }))] }));
98
+ }
@@ -0,0 +1,13 @@
1
+ import React from 'react';
2
+ import type { WidgetConfig } from './types';
3
+ export interface RlseWidgetMenuProps extends WidgetConfig {
4
+ /** Control open state externally */
5
+ isOpen?: boolean;
6
+ /** Callback when menu should close */
7
+ onClose?: () => void;
8
+ /** Container style for the dropdown */
9
+ dropdownStyle?: React.CSSProperties;
10
+ /** Max height of the dropdown */
11
+ maxHeight?: number;
12
+ }
13
+ export declare function RlseWidgetMenu(props: RlseWidgetMenuProps): React.ReactElement | null;
@@ -0,0 +1,151 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useState } from 'react';
3
+ import { formatChangeDate, getReleaseNotesUrl } from '@rlse/widget-core';
4
+ import { DEFAULT_CONFIG } from './types';
5
+ import { useWidgetData } from './useWidgetData';
6
+ import { markAllChangesAsSeen, getUnreadCount } from './storage';
7
+ export function RlseWidgetMenu(props) {
8
+ const config = { ...DEFAULT_CONFIG, ...props };
9
+ const { isOpen: controlledIsOpen, onClose, dropdownStyle, maxHeight = 400, } = props;
10
+ // Internal state if not controlled externally
11
+ const [internalOpen, setInternalOpen] = useState(false);
12
+ const isOpen = controlledIsOpen ?? internalOpen;
13
+ const { changes, isLoading } = useWidgetData(config.orgSlug, config.appSlug, config.limit, config.baseUrl);
14
+ const handleClose = useCallback(() => {
15
+ if (onClose) {
16
+ onClose();
17
+ }
18
+ else {
19
+ setInternalOpen(false);
20
+ }
21
+ }, [onClose]);
22
+ const handleMarkAsRead = useCallback(() => {
23
+ const changeIds = changes.map((c) => c._id);
24
+ markAllChangesAsSeen(config.orgSlug, changeIds);
25
+ window.dispatchEvent(new Event('storage'));
26
+ handleClose();
27
+ }, [changes, config.orgSlug, handleClose]);
28
+ const unreadCount = getUnreadCount(config.orgSlug, changes);
29
+ const releaseNotesUrl = getReleaseNotesUrl(config.baseUrl, config.orgSlug, config.appSlug);
30
+ const defaultDropdownStyle = {
31
+ position: 'absolute',
32
+ top: '100%',
33
+ right: 0,
34
+ marginTop: 8,
35
+ background: 'var(--background, white)',
36
+ borderRadius: 12,
37
+ border: '1px solid var(--border, #e2e8f0)',
38
+ boxShadow: '0 10px 40px -10px rgba(0, 0, 0, 0.2)',
39
+ width: 320,
40
+ maxHeight,
41
+ display: 'flex',
42
+ flexDirection: 'column',
43
+ overflow: 'hidden',
44
+ zIndex: 1000,
45
+ };
46
+ const headerStyle = {
47
+ display: 'flex',
48
+ alignItems: 'center',
49
+ justifyContent: 'space-between',
50
+ padding: '12px 16px',
51
+ borderBottom: '1px solid var(--border, #e2e8f0)',
52
+ };
53
+ const titleStyle = {
54
+ margin: 0,
55
+ fontSize: 14,
56
+ fontWeight: 600,
57
+ };
58
+ const contentStyle = {
59
+ overflowY: 'auto',
60
+ padding: '0 16px',
61
+ flex: 1,
62
+ };
63
+ const footerStyle = {
64
+ display: 'flex',
65
+ alignItems: 'center',
66
+ justifyContent: 'space-between',
67
+ padding: '10px 16px',
68
+ borderTop: '1px solid var(--border, #e2e8f0)',
69
+ };
70
+ return (_jsxs("div", { style: { position: 'relative' }, children: [_jsxs("button", { onClick: () => setInternalOpen(!internalOpen), style: {
71
+ display: 'flex',
72
+ alignItems: 'center',
73
+ gap: 8,
74
+ padding: '8px 12px',
75
+ borderRadius: 8,
76
+ border: 'none',
77
+ background: 'transparent',
78
+ cursor: 'pointer',
79
+ fontSize: 14,
80
+ fontWeight: 500,
81
+ color: 'inherit',
82
+ }, children: [_jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", 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.275z" }), _jsx("path", { d: "M5 3v4" }), _jsx("path", { d: "M19 17v4" }), _jsx("path", { d: "M3 5h4" }), _jsx("path", { d: "M17 19h4" })] }), config.triggerLabel, unreadCount > 0 && (_jsx("span", { style: {
83
+ fontSize: 11,
84
+ fontWeight: 600,
85
+ minWidth: 18,
86
+ height: 18,
87
+ padding: '0 5px',
88
+ borderRadius: 9,
89
+ background: config.primaryColor || '#3b82f6',
90
+ color: 'white',
91
+ display: 'flex',
92
+ alignItems: 'center',
93
+ justifyContent: 'center',
94
+ }, children: unreadCount > 99 ? '99+' : unreadCount }))] }), isOpen && (_jsxs(_Fragment, { children: [_jsx("div", { onClick: handleClose, style: {
95
+ position: 'fixed',
96
+ inset: 0,
97
+ zIndex: 999,
98
+ } }), _jsxs("div", { style: { ...defaultDropdownStyle, ...dropdownStyle }, children: [_jsxs("div", { style: headerStyle, children: [_jsx("h2", { style: titleStyle, children: config.modalTitle }), _jsx("button", { onClick: handleClose, style: {
99
+ background: 'none',
100
+ border: 'none',
101
+ cursor: 'pointer',
102
+ padding: 4,
103
+ display: 'flex',
104
+ alignItems: 'center',
105
+ justifyContent: 'center',
106
+ color: '#64748b',
107
+ }, children: _jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", 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: contentStyle, children: isLoading ? (_jsx("div", { style: { padding: 32, textAlign: 'center' }, 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", 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: {
108
+ padding: 32,
109
+ textAlign: 'center',
110
+ color: '#64748b',
111
+ fontSize: 13,
112
+ }, children: _jsx("p", { children: "No release notes yet." }) })) : (changes.map((change) => (_jsxs("article", { style: {
113
+ padding: '12px 0',
114
+ borderBottom: '1px solid var(--border, #e2e8f0)',
115
+ }, children: [_jsxs("div", { style: {
116
+ display: 'flex',
117
+ alignItems: 'center',
118
+ justifyContent: 'space-between',
119
+ gap: 8,
120
+ marginBottom: 2,
121
+ }, children: [_jsx("h3", { style: { margin: 0, fontSize: 13, fontWeight: 600 }, children: change.changeName }), config.showStatus && (_jsx("span", { style: {
122
+ fontSize: 10,
123
+ fontWeight: 500,
124
+ padding: '1px 6px',
125
+ borderRadius: 10,
126
+ background: 'rgba(59, 130, 246, 0.1)',
127
+ color: config.primaryColor || '#3b82f6',
128
+ border: `1px solid ${config.primaryColor || '#3b82f6'}`,
129
+ whiteSpace: 'nowrap',
130
+ }, children: change.currentStatus }))] }), _jsx("p", { style: {
131
+ margin: '2px 0',
132
+ fontSize: 12,
133
+ color: '#64748b',
134
+ lineHeight: 1.4,
135
+ }, children: change.changeSummary }), config.showDates && (_jsx("div", { style: { marginTop: 4, fontSize: 10, color: '#94a3b8' }, children: formatChangeDate(change._creationTime) }))] }, change._id)))) }), !isLoading && changes.length > 0 && (_jsxs("div", { style: footerStyle, children: [_jsx("button", { onClick: handleMarkAsRead, disabled: unreadCount === 0, style: {
136
+ fontSize: 12,
137
+ padding: '5px 10px',
138
+ borderRadius: 6,
139
+ border: 'none',
140
+ background: config.primaryColor || '#3b82f6',
141
+ color: 'white',
142
+ cursor: unreadCount === 0 ? 'default' : 'pointer',
143
+ fontWeight: 500,
144
+ opacity: unreadCount === 0 ? 0.5 : 1,
145
+ }, children: unreadCount === 0 ? 'All caught up' : 'Mark read' }), _jsx("a", { href: releaseNotesUrl, target: "_blank", rel: "noopener", style: {
146
+ fontSize: 12,
147
+ color: config.primaryColor || '#3b82f6',
148
+ textDecoration: 'none',
149
+ fontWeight: 500,
150
+ }, children: "View all \u2192" })] }))] })] }))] }));
151
+ }
@@ -0,0 +1,53 @@
1
+ import { OnInit, OnDestroy } from '@angular/core';
2
+ import { WidgetDataService } from '../services/widget-data.service';
3
+ import type { WidgetConfig, Change } from '@rlse/widget-core';
4
+ export declare class RlseWidgetComponent implements OnInit, OnDestroy {
5
+ private widgetDataService;
6
+ orgSlug: string;
7
+ appSlug?: string;
8
+ trigger: 'auto' | 'manual' | 'both';
9
+ limit: number;
10
+ showStatus: boolean;
11
+ showDates: boolean;
12
+ position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
13
+ theme: 'light' | 'dark' | 'auto';
14
+ primaryColor?: string;
15
+ triggerLabel: string;
16
+ modalTitle: string;
17
+ baseUrl: string;
18
+ config: Required<WidgetConfig>;
19
+ changes: Change[];
20
+ isLoading: boolean;
21
+ isOpen: boolean;
22
+ unreadCount: number;
23
+ releaseNotesUrl: string;
24
+ private destroy$;
25
+ constructor(widgetDataService: WidgetDataService);
26
+ ngOnInit(): void;
27
+ ngOnDestroy(): void;
28
+ private loadChanges;
29
+ markAsRead(): void;
30
+ onOverlayClick(event: MouseEvent): void;
31
+ renderDescription(content: string): string;
32
+ formatDate(timestamp: number): string;
33
+ get triggerStyle(): string;
34
+ get badgeStyle(): string;
35
+ get overlayStyle(): string;
36
+ get modalStyle(): string;
37
+ get headerStyle(): string;
38
+ get titleStyle(): string;
39
+ get closeButtonStyle(): string;
40
+ get contentStyle(): string;
41
+ get centeredStyle(): string;
42
+ get emptyStyle(): string;
43
+ get changeCardStyle(): string;
44
+ get changeHeaderStyle(): string;
45
+ get changeTitleStyle(): string;
46
+ get statusBadgeStyle(): string;
47
+ get summaryStyle(): string;
48
+ get descriptionStyle(): string;
49
+ get dateStyle(): string;
50
+ get footerStyle(): string;
51
+ get markReadButtonStyle(): string;
52
+ get viewAllLinkStyle(): string;
53
+ }