@papernote/ui 1.10.12 → 1.10.14

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 (59) hide show
  1. package/dist/components/AchievementBadge.d.ts +37 -0
  2. package/dist/components/AchievementBadge.d.ts.map +1 -0
  3. package/dist/components/AchievementUnlock.d.ts +31 -0
  4. package/dist/components/AchievementUnlock.d.ts.map +1 -0
  5. package/dist/components/ActivityFeed.d.ts +42 -0
  6. package/dist/components/ActivityFeed.d.ts.map +1 -0
  7. package/dist/components/CollaboratorAvatars.d.ts +33 -0
  8. package/dist/components/CollaboratorAvatars.d.ts.map +1 -0
  9. package/dist/components/InviteCard.d.ts +33 -0
  10. package/dist/components/InviteCard.d.ts.map +1 -0
  11. package/dist/components/MotivationalMessage.d.ts +31 -0
  12. package/dist/components/MotivationalMessage.d.ts.map +1 -0
  13. package/dist/components/PermissionBadge.d.ts +25 -0
  14. package/dist/components/PermissionBadge.d.ts.map +1 -0
  15. package/dist/components/ProgressCelebration.d.ts +30 -0
  16. package/dist/components/ProgressCelebration.d.ts.map +1 -0
  17. package/dist/components/SharedBadge.d.ts +28 -0
  18. package/dist/components/SharedBadge.d.ts.map +1 -0
  19. package/dist/components/StreakBadge.d.ts +27 -0
  20. package/dist/components/StreakBadge.d.ts.map +1 -0
  21. package/dist/components/SuccessCheck.d.ts +27 -0
  22. package/dist/components/SuccessCheck.d.ts.map +1 -0
  23. package/dist/components/index.d.ts +24 -0
  24. package/dist/components/index.d.ts.map +1 -1
  25. package/dist/hooks/useDelighters.d.ts +55 -0
  26. package/dist/hooks/useDelighters.d.ts.map +1 -0
  27. package/dist/index.d.ts +382 -2
  28. package/dist/index.esm.js +1385 -486
  29. package/dist/index.esm.js.map +1 -1
  30. package/dist/index.js +1395 -484
  31. package/dist/index.js.map +1 -1
  32. package/dist/styles.css +201 -0
  33. package/package.json +1 -1
  34. package/src/components/AchievementBadge.stories.tsx +290 -0
  35. package/src/components/AchievementBadge.tsx +196 -0
  36. package/src/components/AchievementUnlock.stories.tsx +345 -0
  37. package/src/components/AchievementUnlock.tsx +157 -0
  38. package/src/components/ActivityFeed.stories.tsx +236 -0
  39. package/src/components/ActivityFeed.tsx +160 -0
  40. package/src/components/Celebration.stories.tsx +3 -3
  41. package/src/components/CollaboratorAvatars.stories.tsx +215 -0
  42. package/src/components/CollaboratorAvatars.tsx +175 -0
  43. package/src/components/InviteCard.stories.tsx +174 -0
  44. package/src/components/InviteCard.tsx +209 -0
  45. package/src/components/MotivationalMessage.stories.tsx +258 -0
  46. package/src/components/MotivationalMessage.tsx +120 -0
  47. package/src/components/PermissionBadge.stories.tsx +208 -0
  48. package/src/components/PermissionBadge.tsx +204 -0
  49. package/src/components/ProgressCelebration.stories.tsx +321 -0
  50. package/src/components/ProgressCelebration.tsx +143 -0
  51. package/src/components/SharedBadge.stories.tsx +210 -0
  52. package/src/components/SharedBadge.tsx +111 -0
  53. package/src/components/StreakBadge.stories.tsx +222 -0
  54. package/src/components/StreakBadge.tsx +132 -0
  55. package/src/components/SuccessCheck.stories.tsx +233 -0
  56. package/src/components/SuccessCheck.tsx +214 -0
  57. package/src/components/Text.stories.tsx +1 -1
  58. package/src/components/index.ts +38 -0
  59. package/src/hooks/useDelighters.ts +133 -0
@@ -0,0 +1,175 @@
1
+ import { forwardRef } from 'react';
2
+ import Tooltip from './Tooltip';
3
+
4
+ export interface Collaborator {
5
+ /** Collaborator's name */
6
+ name: string;
7
+ /** Optional avatar URL */
8
+ avatar?: string;
9
+ }
10
+
11
+ export interface CollaboratorAvatarsProps {
12
+ /** Array of collaborators to display */
13
+ collaborators: Collaborator[];
14
+ /** Maximum number of avatars to display before showing overflow */
15
+ max?: number;
16
+ /** Size of the avatars */
17
+ size?: 'sm' | 'md' | 'lg';
18
+ /** Click handler for the avatar stack */
19
+ onClick?: () => void;
20
+ /** Additional CSS classes */
21
+ className?: string;
22
+ }
23
+
24
+ const sizeStyles = {
25
+ sm: {
26
+ container: 'w-6 h-6 text-xs',
27
+ overlap: '-ml-2',
28
+ border: 'ring-2',
29
+ },
30
+ md: {
31
+ container: 'w-8 h-8 text-sm',
32
+ overlap: '-ml-2.5',
33
+ border: 'ring-2',
34
+ },
35
+ lg: {
36
+ container: 'w-10 h-10 text-base',
37
+ overlap: '-ml-3',
38
+ border: 'ring-2',
39
+ },
40
+ };
41
+
42
+ // Generate consistent color from name
43
+ function getAvatarColor(name: string): string {
44
+ const colors = [
45
+ 'bg-accent-500',
46
+ 'bg-success-500',
47
+ 'bg-warning-500',
48
+ 'bg-error-500',
49
+ 'bg-primary-500',
50
+ ];
51
+
52
+ let hash = 0;
53
+ for (let i = 0; i < name.length; i++) {
54
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
55
+ }
56
+
57
+ return colors[Math.abs(hash) % colors.length];
58
+ }
59
+
60
+ // Get initials from name
61
+ function getInitials(name: string): string {
62
+ const parts = name.trim().split(/\s+/);
63
+ if (parts.length >= 2) {
64
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
65
+ }
66
+ return name.slice(0, 2).toUpperCase();
67
+ }
68
+
69
+ /**
70
+ * CollaboratorAvatars - Stacked avatar display for collaborators.
71
+ *
72
+ * Features:
73
+ * - Displays avatars in an overlapping stack
74
+ * - Shows overflow indicator (+N) when exceeding max
75
+ * - Falls back to initials when no avatar image
76
+ * - Consistent color generation from names
77
+ * - Tooltip showing collaborator names
78
+ * - Three sizes: sm, md, lg
79
+ * - Optional click handler
80
+ */
81
+ export const CollaboratorAvatars = forwardRef<HTMLDivElement, CollaboratorAvatarsProps>(
82
+ function CollaboratorAvatars(
83
+ {
84
+ collaborators,
85
+ max = 3,
86
+ size = 'md',
87
+ onClick,
88
+ className = '',
89
+ },
90
+ ref
91
+ ) {
92
+ const styles = sizeStyles[size];
93
+ const visible = collaborators.slice(0, max);
94
+ const overflow = collaborators.length - max;
95
+ const hasOverflow = overflow > 0;
96
+
97
+ const allNames = collaborators.map((c) => c.name).join(', ');
98
+ const tooltipContent = (
99
+ <div className="text-center">
100
+ <div className="font-medium mb-1">Collaborators</div>
101
+ <div className="text-ink-300">{allNames}</div>
102
+ </div>
103
+ );
104
+
105
+ const avatarStack = (
106
+ <div
107
+ ref={ref}
108
+ className={`
109
+ inline-flex items-center
110
+ ${onClick ? 'cursor-pointer hover:opacity-90 transition-opacity' : ''}
111
+ ${className}
112
+ `}
113
+ onClick={onClick}
114
+ role={onClick ? 'button' : 'group'}
115
+ aria-label={`${collaborators.length} collaborator${collaborators.length !== 1 ? 's' : ''}`}
116
+ >
117
+ {visible.map((collaborator, index) => (
118
+ <div
119
+ key={`${collaborator.name}-${index}`}
120
+ className={`
121
+ ${styles.container}
122
+ ${index > 0 ? styles.overlap : ''}
123
+ ${styles.border}
124
+ ring-white
125
+ rounded-full
126
+ flex items-center justify-center
127
+ overflow-hidden
128
+ ${collaborator.avatar ? '' : getAvatarColor(collaborator.name)}
129
+ text-white font-medium
130
+ shrink-0
131
+ `}
132
+ style={{ zIndex: visible.length - index }}
133
+ >
134
+ {collaborator.avatar ? (
135
+ <img
136
+ src={collaborator.avatar}
137
+ alt={collaborator.name}
138
+ className="w-full h-full object-cover"
139
+ />
140
+ ) : (
141
+ <span>{getInitials(collaborator.name)}</span>
142
+ )}
143
+ </div>
144
+ ))}
145
+
146
+ {hasOverflow && (
147
+ <div
148
+ className={`
149
+ ${styles.container}
150
+ ${styles.overlap}
151
+ ${styles.border}
152
+ ring-white
153
+ rounded-full
154
+ flex items-center justify-center
155
+ bg-paper-300
156
+ text-ink-600 font-medium
157
+ shrink-0
158
+ `}
159
+ style={{ zIndex: 0 }}
160
+ >
161
+ +{overflow}
162
+ </div>
163
+ )}
164
+ </div>
165
+ );
166
+
167
+ return (
168
+ <Tooltip content={tooltipContent} position="top">
169
+ {avatarStack}
170
+ </Tooltip>
171
+ );
172
+ }
173
+ );
174
+
175
+ export default CollaboratorAvatars;
@@ -0,0 +1,174 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { useState } from 'react';
3
+ import { InviteCard, PendingInvite } from './InviteCard';
4
+ import Stack from './Stack';
5
+ import Text from './Text';
6
+
7
+ const meta: Meta<typeof InviteCard> = {
8
+ title: 'Collaboration/InviteCard',
9
+ component: InviteCard,
10
+ parameters: {
11
+ layout: 'centered',
12
+ docs: {
13
+ description: {
14
+ component: 'Card for inviting collaborators via email. Features email input with validation, pending invitations list, and cancel functionality.',
15
+ },
16
+ },
17
+ },
18
+ tags: ['autodocs'],
19
+ argTypes: {
20
+ loading: {
21
+ control: 'boolean',
22
+ description: 'Whether an invitation is being sent',
23
+ },
24
+ maxPending: {
25
+ control: { type: 'number', min: 1, max: 10 },
26
+ description: 'Maximum pending invites to display',
27
+ },
28
+ },
29
+ };
30
+
31
+ export default meta;
32
+ type Story = StoryObj<typeof InviteCard>;
33
+
34
+ const now = new Date();
35
+ const samplePending: PendingInvite[] = [
36
+ { email: 'alice@example.com', sentAt: new Date(now.getTime() - 5 * 60000) },
37
+ { email: 'bob@example.com', sentAt: new Date(now.getTime() - 2 * 3600000) },
38
+ { email: 'carol@example.com', sentAt: new Date(now.getTime() - 24 * 3600000) },
39
+ ];
40
+
41
+ // Basic example
42
+ export const Default: Story = {
43
+ args: {
44
+ onInvite: (email) => alert(`Invited: ${email}`),
45
+ },
46
+ };
47
+
48
+ // With pending invites
49
+ export const WithPending: Story = {
50
+ args: {
51
+ onInvite: (email) => alert(`Invited: ${email}`),
52
+ pending: samplePending,
53
+ onCancelInvite: (email) => alert(`Cancelled: ${email}`),
54
+ },
55
+ };
56
+
57
+ // Loading state
58
+ export const Loading: Story = {
59
+ args: {
60
+ onInvite: () => {},
61
+ loading: true,
62
+ },
63
+ };
64
+
65
+ // Interactive demo
66
+ export const Interactive: Story = {
67
+ render: function InteractiveDemo() {
68
+ const [pending, setPending] = useState<PendingInvite[]>([]);
69
+ const [loading, setLoading] = useState(false);
70
+
71
+ const handleInvite = (email: string) => {
72
+ setLoading(true);
73
+ setTimeout(() => {
74
+ setPending((prev) => [
75
+ { email, sentAt: new Date() },
76
+ ...prev,
77
+ ]);
78
+ setLoading(false);
79
+ }, 1000);
80
+ };
81
+
82
+ const handleCancel = (email: string) => {
83
+ setPending((prev) => prev.filter((p) => p.email !== email));
84
+ };
85
+
86
+ return (
87
+ <div className="w-96">
88
+ <InviteCard
89
+ onInvite={handleInvite}
90
+ pending={pending}
91
+ loading={loading}
92
+ onCancelInvite={handleCancel}
93
+ />
94
+ </div>
95
+ );
96
+ },
97
+ };
98
+
99
+ // Many pending invites
100
+ export const ManyPending: Story = {
101
+ args: {
102
+ onInvite: (email) => alert(`Invited: ${email}`),
103
+ pending: [
104
+ ...samplePending,
105
+ { email: 'david@example.com', sentAt: new Date() },
106
+ { email: 'eve@example.com', sentAt: new Date() },
107
+ { email: 'frank@example.com', sentAt: new Date() },
108
+ { email: 'grace@example.com', sentAt: new Date() },
109
+ ],
110
+ maxPending: 3,
111
+ onCancelInvite: (email) => alert(`Cancelled: ${email}`),
112
+ },
113
+ };
114
+
115
+ // Without cancel functionality
116
+ export const WithoutCancel: Story = {
117
+ args: {
118
+ onInvite: (email) => alert(`Invited: ${email}`),
119
+ pending: samplePending,
120
+ },
121
+ };
122
+
123
+ // In context - Modal content
124
+ export const ModalContext: Story = {
125
+ render: () => (
126
+ <div className="bg-paper-50 p-6 rounded-xl w-[450px]">
127
+ <Stack gap="lg">
128
+ <div>
129
+ <Text size="xl" weight="bold">Share "Q4 Budget"</Text>
130
+ <Text size="sm" className="text-ink-500 mt-1">
131
+ Invite team members to collaborate on this document.
132
+ </Text>
133
+ </div>
134
+ <InviteCard
135
+ onInvite={(email) => alert(`Invited: ${email}`)}
136
+ pending={samplePending.slice(0, 2)}
137
+ onCancelInvite={(email) => alert(`Cancelled: ${email}`)}
138
+ />
139
+ </Stack>
140
+ </div>
141
+ ),
142
+ };
143
+
144
+ // Empty state
145
+ export const EmptyState: Story = {
146
+ render: () => (
147
+ <div className="w-96">
148
+ <InviteCard
149
+ onInvite={(email) => alert(`Invited: ${email}`)}
150
+ pending={[]}
151
+ />
152
+ </div>
153
+ ),
154
+ };
155
+
156
+ // Validation demo
157
+ export const ValidationDemo: Story = {
158
+ render: () => (
159
+ <Stack gap="md" className="w-96">
160
+ <Text size="sm" className="text-ink-500">
161
+ Try submitting with:
162
+ </Text>
163
+ <ul className="text-sm text-ink-500 list-disc list-inside">
164
+ <li>Empty email</li>
165
+ <li>Invalid email format</li>
166
+ <li>Already invited email (alice@example.com)</li>
167
+ </ul>
168
+ <InviteCard
169
+ onInvite={(email) => alert(`Would invite: ${email}`)}
170
+ pending={[{ email: 'alice@example.com', sentAt: new Date() }]}
171
+ />
172
+ </Stack>
173
+ ),
174
+ };
@@ -0,0 +1,209 @@
1
+ import { useState } from 'react';
2
+ import { Send, Clock, X, Mail } from 'lucide-react';
3
+ import Button from './Button';
4
+ import Input from './Input';
5
+ import Stack from './Stack';
6
+ import Text from './Text';
7
+
8
+ export interface PendingInvite {
9
+ /** Email address of the invitee */
10
+ email: string;
11
+ /** When the invitation was sent */
12
+ sentAt: Date;
13
+ }
14
+
15
+ export interface InviteCardProps {
16
+ /** Callback when an invitation is sent */
17
+ onInvite: (email: string) => void;
18
+ /** List of pending invitations */
19
+ pending?: PendingInvite[];
20
+ /** Whether an invitation is being sent */
21
+ loading?: boolean;
22
+ /** Maximum pending invites to display */
23
+ maxPending?: number;
24
+ /** Callback to cancel a pending invite */
25
+ onCancelInvite?: (email: string) => void;
26
+ /** Additional CSS classes */
27
+ className?: string;
28
+ }
29
+
30
+ // Format relative time
31
+ function formatRelativeTime(date: Date): string {
32
+ const now = new Date();
33
+ const diffMs = now.getTime() - date.getTime();
34
+ const diffMins = Math.floor(diffMs / 60000);
35
+ const diffHours = Math.floor(diffMs / 3600000);
36
+ const diffDays = Math.floor(diffMs / 86400000);
37
+
38
+ if (diffMins < 1) return 'Just now';
39
+ if (diffMins < 60) return `${diffMins}m ago`;
40
+ if (diffHours < 24) return `${diffHours}h ago`;
41
+ if (diffDays < 7) return `${diffDays}d ago`;
42
+
43
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
44
+ }
45
+
46
+ // Simple email validation
47
+ function isValidEmail(email: string): boolean {
48
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
49
+ }
50
+
51
+ /**
52
+ * InviteCard - Card for inviting collaborators via email.
53
+ *
54
+ * Features:
55
+ * - Email input with validation
56
+ * - Send button with loading state
57
+ * - List of pending invitations
58
+ * - Cancel pending invites
59
+ * - Relative timestamps for pending
60
+ */
61
+ export function InviteCard({
62
+ onInvite,
63
+ pending = [],
64
+ loading = false,
65
+ maxPending = 5,
66
+ onCancelInvite,
67
+ className = '',
68
+ }: InviteCardProps) {
69
+ const [email, setEmail] = useState('');
70
+ const [error, setError] = useState<string | null>(null);
71
+
72
+ const displayedPending = pending.slice(0, maxPending);
73
+ const hasMorePending = pending.length > maxPending;
74
+
75
+ const handleSubmit = (e: React.FormEvent) => {
76
+ e.preventDefault();
77
+
78
+ const trimmedEmail = email.trim();
79
+
80
+ if (!trimmedEmail) {
81
+ setError('Email is required');
82
+ return;
83
+ }
84
+
85
+ if (!isValidEmail(trimmedEmail)) {
86
+ setError('Please enter a valid email address');
87
+ return;
88
+ }
89
+
90
+ // Check if already invited
91
+ if (pending.some((p) => p.email.toLowerCase() === trimmedEmail.toLowerCase())) {
92
+ setError('This email has already been invited');
93
+ return;
94
+ }
95
+
96
+ setError(null);
97
+ onInvite(trimmedEmail);
98
+ setEmail('');
99
+ };
100
+
101
+ const handleEmailChange = (value: string) => {
102
+ setEmail(value);
103
+ if (error) {
104
+ setError(null);
105
+ }
106
+ };
107
+
108
+ return (
109
+ <div
110
+ className={`
111
+ bg-white
112
+ rounded-xl
113
+ border border-paper-200
114
+ shadow-card
115
+ p-4
116
+ ${className}
117
+ `}
118
+ >
119
+ <Stack gap="md">
120
+ {/* Header */}
121
+ <Stack direction="horizontal" gap="sm" align="center">
122
+ <div className="p-2 bg-accent-100 rounded-lg">
123
+ <Mail className="w-5 h-5 text-accent-600" />
124
+ </div>
125
+ <div>
126
+ <Text weight="semibold" className="text-ink-800">Invite People</Text>
127
+ <Text size="sm" className="text-ink-500">Share access via email</Text>
128
+ </div>
129
+ </Stack>
130
+
131
+ {/* Invite form */}
132
+ <form onSubmit={handleSubmit}>
133
+ <Stack direction="horizontal" gap="sm">
134
+ <div className="flex-1">
135
+ <Input
136
+ type="email"
137
+ placeholder="Enter email address"
138
+ value={email}
139
+ onChange={(e) => handleEmailChange(e.target.value)}
140
+ validationState={error ? 'error' : undefined}
141
+ validationMessage={error || undefined}
142
+ disabled={loading}
143
+ />
144
+ </div>
145
+ <Button
146
+ type="submit"
147
+ variant="primary"
148
+ loading={loading}
149
+ disabled={!email.trim()}
150
+ icon={<Send className="w-4 h-4" />}
151
+ >
152
+ Send
153
+ </Button>
154
+ </Stack>
155
+ </form>
156
+
157
+ {/* Pending invites */}
158
+ {displayedPending.length > 0 && (
159
+ <Stack gap="sm">
160
+ <Stack direction="horizontal" gap="xs" align="center">
161
+ <Clock className="w-4 h-4 text-ink-400" />
162
+ <Text size="sm" className="text-ink-500">
163
+ Pending invitations ({pending.length})
164
+ </Text>
165
+ </Stack>
166
+
167
+ <div className="border border-paper-200 rounded-lg divide-y divide-paper-200">
168
+ {displayedPending.map((invite) => (
169
+ <div
170
+ key={invite.email}
171
+ className="px-3 py-2 flex items-center justify-between"
172
+ >
173
+ <Stack gap="xs">
174
+ <Text size="sm" weight="medium" className="text-ink-700">
175
+ {invite.email}
176
+ </Text>
177
+ <Text size="xs" className="text-ink-400">
178
+ Sent {formatRelativeTime(invite.sentAt)}
179
+ </Text>
180
+ </Stack>
181
+
182
+ {onCancelInvite && (
183
+ <button
184
+ onClick={() => onCancelInvite(invite.email)}
185
+ className="p-1 rounded hover:bg-paper-100 text-ink-400 hover:text-error-500 transition-colors"
186
+ aria-label={`Cancel invitation to ${invite.email}`}
187
+ >
188
+ <X className="w-4 h-4" />
189
+ </button>
190
+ )}
191
+ </div>
192
+ ))}
193
+
194
+ {hasMorePending && (
195
+ <div className="px-3 py-2 text-center">
196
+ <Text size="sm" className="text-ink-400">
197
+ +{pending.length - maxPending} more
198
+ </Text>
199
+ </div>
200
+ )}
201
+ </div>
202
+ </Stack>
203
+ )}
204
+ </Stack>
205
+ </div>
206
+ );
207
+ }
208
+
209
+ export default InviteCard;