@linktr.ee/messaging-react 1.31.0-rc-1776677746 → 1.31.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 (35) hide show
  1. package/dist/{Creator-DGe3CQ_j.js → Card-C5t3dZ5q.js} +177 -150
  2. package/dist/Card-C5t3dZ5q.js.map +1 -0
  3. package/dist/Card-Cn2va-Qr.js +205 -0
  4. package/dist/Card-Cn2va-Qr.js.map +1 -0
  5. package/dist/index.d.ts +35 -30
  6. package/dist/index.js +951 -956
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/components/ChannelView.tsx +24 -36
  10. package/src/components/CustomMessage/CustomMessage.stories.tsx +1 -14
  11. package/src/components/CustomMessage/context.tsx +20 -0
  12. package/src/components/CustomMessage/index.tsx +39 -28
  13. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +8 -13
  14. package/src/components/LockedAttachment/components/Creator/Card.tsx +159 -0
  15. package/src/components/LockedAttachment/components/Creator/CardAudioPreview.tsx +161 -0
  16. package/src/components/LockedAttachment/components/Creator/CardCollapsedThumbnail.tsx +58 -0
  17. package/src/components/LockedAttachment/components/Creator/CardImagePreview.tsx +56 -0
  18. package/src/components/LockedAttachment/components/Creator/CardVideoPreview.tsx +91 -0
  19. package/src/components/LockedAttachment/components/Creator/index.tsx +2 -0
  20. package/src/components/LockedAttachment/components/Visitor/Card.tsx +186 -0
  21. package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +71 -0
  22. package/src/components/LockedAttachment/components/Visitor/CardImagePreview.tsx +39 -0
  23. package/src/components/LockedAttachment/components/Visitor/CardMediaPreview.tsx +36 -0
  24. package/src/components/LockedAttachment/components/Visitor/CardThumbnailPreview.tsx +45 -0
  25. package/src/components/LockedAttachment/components/Visitor/index.ts +2 -0
  26. package/src/components/LockedAttachment/index.tsx +16 -23
  27. package/src/components/LockedAttachment/types.ts +14 -1
  28. package/src/components/MessagingShell/index.tsx +0 -6
  29. package/src/index.ts +4 -1
  30. package/src/types.ts +0 -21
  31. package/dist/Creator-DGe3CQ_j.js.map +0 -1
  32. package/dist/Visitor-DyJTWB2_.js +0 -204
  33. package/dist/Visitor-DyJTWB2_.js.map +0 -1
  34. package/src/components/LockedAttachment/components/Creator.tsx +0 -470
  35. package/src/components/LockedAttachment/components/Visitor.tsx +0 -356
@@ -0,0 +1,161 @@
1
+ import { PauseIcon, PlayIcon } from '@phosphor-icons/react'
2
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
3
+
4
+ import CollapsedThumbnail from './CardCollapsedThumbnail'
5
+
6
+ interface AudioPreviewProps {
7
+ sourceUrl?: string
8
+ thumbnailUrl?: string
9
+ mimeType: string
10
+ }
11
+
12
+ const AudioPreview: React.FC<AudioPreviewProps> = (props) => {
13
+ const { sourceUrl, thumbnailUrl, mimeType } = props
14
+ const [playing, setPlaying] = useState(false)
15
+ const [played, setPlayed] = useState(0)
16
+ const [seeking, setSeeking] = useState(false)
17
+ const audioRef = useRef<HTMLAudioElement>(null)
18
+ const trackRef = useRef<HTMLDivElement>(null)
19
+ const rafRef = useRef<number | null>(null)
20
+
21
+ useEffect(() => {
22
+ const el = audioRef.current
23
+ if (!el) return
24
+ if (playing) {
25
+ void el.play().catch(() => setPlaying(false))
26
+ } else {
27
+ el.pause()
28
+ }
29
+ }, [playing])
30
+
31
+ useEffect(() => {
32
+ if (!playing) {
33
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
34
+ return
35
+ }
36
+ const tick = () => {
37
+ const el = audioRef.current
38
+ if (el && el.duration && !seeking) setPlayed(el.currentTime / el.duration)
39
+ rafRef.current = requestAnimationFrame(tick)
40
+ }
41
+ rafRef.current = requestAnimationFrame(tick)
42
+ return () => {
43
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
44
+ }
45
+ }, [playing, seeking])
46
+
47
+ const [audioReady, setAudioReady] = useState(false)
48
+
49
+ const getFraction = useCallback(
50
+ (e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) => {
51
+ const track = trackRef.current
52
+ if (!track) return 0
53
+ const clientX =
54
+ 'touches' in e
55
+ ? (e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0)
56
+ : e.clientX
57
+ const rect = track.getBoundingClientRect()
58
+ return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
59
+ },
60
+ []
61
+ )
62
+
63
+ const seekTo = useCallback((fraction: number) => {
64
+ const el = audioRef.current
65
+ if (el && el.duration) el.currentTime = fraction * el.duration
66
+ }, [])
67
+
68
+ useEffect(() => {
69
+ if (!seeking) return
70
+ const onMove = (e: MouseEvent | TouchEvent) => {
71
+ const f = getFraction(e)
72
+ setPlayed(f)
73
+ seekTo(f)
74
+ }
75
+ const onUp = (e: MouseEvent | TouchEvent) => {
76
+ setSeeking(false)
77
+ seekTo(getFraction(e))
78
+ }
79
+ window.addEventListener('mousemove', onMove)
80
+ window.addEventListener('mouseup', onUp)
81
+ window.addEventListener('touchmove', onMove, { passive: true })
82
+ window.addEventListener('touchend', onUp)
83
+ return () => {
84
+ window.removeEventListener('mousemove', onMove)
85
+ window.removeEventListener('mouseup', onUp)
86
+ window.removeEventListener('touchmove', onMove)
87
+ window.removeEventListener('touchend', onUp)
88
+ }
89
+ }, [seeking, getFraction, seekTo])
90
+
91
+ const toggle = useCallback(() => setPlaying((p) => !p), [])
92
+
93
+ return (
94
+ <div className="relative">
95
+ {sourceUrl && (
96
+ <audio
97
+ ref={audioRef}
98
+ src={sourceUrl}
99
+ loop
100
+ onCanPlay={() => setAudioReady(true)}
101
+ onEnded={() => {
102
+ setPlaying(false)
103
+ setPlayed(0)
104
+ }}
105
+ >
106
+ <track kind="captions" />
107
+ </audio>
108
+ )}
109
+ <CollapsedThumbnail
110
+ thumbnailUrl={thumbnailUrl}
111
+ mimeType={mimeType}
112
+ overlayIcon={
113
+ sourceUrl && audioReady ? (playing ? PauseIcon : PlayIcon) : undefined
114
+ }
115
+ onClick={sourceUrl && audioReady ? toggle : undefined}
116
+ />
117
+ {sourceUrl && audioReady && (
118
+ <div className="absolute inset-x-0 bottom-0 px-3 pb-2.5 pt-6 bg-gradient-to-t from-black/40 to-transparent">
119
+ <div
120
+ ref={trackRef}
121
+ role="slider"
122
+ aria-label="Playback position"
123
+ aria-valuenow={Math.round(played * 100)}
124
+ aria-valuemin={0}
125
+ aria-valuemax={100}
126
+ tabIndex={0}
127
+ className="relative flex h-4 w-full cursor-pointer items-center"
128
+ onMouseDown={(e) => {
129
+ e.stopPropagation()
130
+ setSeeking(true)
131
+ const f = getFraction(e)
132
+ setPlayed(f)
133
+ seekTo(f)
134
+ }}
135
+ onTouchStart={(e) => {
136
+ e.stopPropagation()
137
+ setSeeking(true)
138
+ const f = getFraction(e)
139
+ setPlayed(f)
140
+ seekTo(f)
141
+ }}
142
+ onClick={(e) => e.stopPropagation()}
143
+ onKeyDown={(e) => {
144
+ if (e.key === 'ArrowRight') seekTo(Math.min(1, played + 0.05))
145
+ if (e.key === 'ArrowLeft') seekTo(Math.max(0, played - 0.05))
146
+ }}
147
+ >
148
+ <div className="w-full overflow-hidden rounded-full bg-white/30 h-1">
149
+ <div
150
+ className="h-full rounded-full bg-white"
151
+ style={{ width: `${Math.round(played * 100)}%` }}
152
+ />
153
+ </div>
154
+ </div>
155
+ </div>
156
+ )}
157
+ </div>
158
+ )
159
+ }
160
+
161
+ export default AudioPreview
@@ -0,0 +1,58 @@
1
+ import classNames from 'classnames'
2
+ import React from 'react'
3
+
4
+ import { renderTypeIcon } from '../../utils/icons'
5
+
6
+ interface CollapsedThumbnailProps {
7
+ thumbnailUrl?: string
8
+ mimeType: string
9
+ overlayIcon?: React.ElementType
10
+ darkOverlay?: boolean
11
+ onClick?: () => void
12
+ }
13
+
14
+ const CollapsedThumbnail: React.FC<CollapsedThumbnailProps> = ({
15
+ thumbnailUrl,
16
+ mimeType,
17
+ overlayIcon: OverlayIcon,
18
+ darkOverlay,
19
+ onClick,
20
+ }) => {
21
+ return (
22
+ <button
23
+ type="button"
24
+ disabled={!onClick}
25
+ className={classNames(
26
+ 'relative aspect-video block w-full overflow-hidden border-0 bg-black/5 p-0 text-left appearance-none',
27
+ { 'cursor-pointer': !!onClick, 'cursor-default': !onClick }
28
+ )}
29
+ onClick={onClick}
30
+ aria-label={OverlayIcon ? 'Toggle preview' : undefined}
31
+ >
32
+ {thumbnailUrl ? (
33
+ <img
34
+ src={thumbnailUrl}
35
+ alt=""
36
+ className="absolute inset-0 h-full w-full object-cover"
37
+ />
38
+ ) : (
39
+ <div className="absolute inset-0 flex items-center justify-center">
40
+ {renderTypeIcon(mimeType, {
41
+ className: 'size-12 text-black/20',
42
+ weight: 'regular',
43
+ })}
44
+ </div>
45
+ )}
46
+ {darkOverlay && (
47
+ <div className="pointer-events-none absolute inset-0 bg-black/30" />
48
+ )}
49
+ {OverlayIcon && (
50
+ <div className="pointer-events-none absolute left-3 top-3 flex size-8 items-center justify-center rounded-full bg-black/60 text-white">
51
+ <OverlayIcon className="size-4" weight="fill" />
52
+ </div>
53
+ )}
54
+ </button>
55
+ )
56
+ }
57
+
58
+ export default CollapsedThumbnail
@@ -0,0 +1,56 @@
1
+ import { EyeIcon, EyeSlashIcon } from '@phosphor-icons/react'
2
+ import { useState } from 'react'
3
+
4
+ import CollapsedThumbnail from './CardCollapsedThumbnail'
5
+
6
+ interface ImagePreviewProps {
7
+ sourceUrl?: string
8
+ thumbnailUrl?: string
9
+ mimeType: string
10
+ title?: string
11
+ }
12
+
13
+ const ImagePreview: React.FC<ImagePreviewProps> = (props) => {
14
+ const { sourceUrl, thumbnailUrl, mimeType, title } = props
15
+ const [expanded, setExpanded] = useState(false)
16
+
17
+ if (expanded && sourceUrl) {
18
+ return (
19
+ <div className="relative">
20
+ <button
21
+ type="button"
22
+ className="block w-full cursor-pointer border-0 p-0 text-left appearance-none"
23
+ onClick={() => setExpanded(false)}
24
+ aria-label="Close preview"
25
+ >
26
+ <img src={sourceUrl} alt={title ?? ''} className="block w-full" />
27
+ </button>
28
+ <CloseButton onClose={() => setExpanded(false)} />
29
+ </div>
30
+ )
31
+ }
32
+
33
+ return (
34
+ <CollapsedThumbnail
35
+ thumbnailUrl={thumbnailUrl}
36
+ mimeType={mimeType}
37
+ overlayIcon={sourceUrl ? EyeSlashIcon : undefined}
38
+ onClick={sourceUrl ? () => setExpanded(true) : undefined}
39
+ />
40
+ )
41
+ }
42
+
43
+ const CloseButton: React.FC<{ onClose: () => void }> = ({ onClose }) => {
44
+ return (
45
+ <button
46
+ type="button"
47
+ onClick={onClose}
48
+ className="absolute left-3 top-3 z-40 flex size-8 items-center justify-center rounded-full bg-black/60 text-white"
49
+ aria-label="Close preview"
50
+ >
51
+ <EyeIcon className="size-4" weight="fill" />
52
+ </button>
53
+ )
54
+ }
55
+
56
+ export default ImagePreview
@@ -0,0 +1,91 @@
1
+ import { EyeIcon, EyeSlashIcon } from '@phosphor-icons/react'
2
+ import classNames from 'classnames'
3
+ import React, { useState } from 'react'
4
+
5
+ import { renderTypeIcon } from '../../utils/icons'
6
+ import MediaPlayer from '../MediaPlayer'
7
+
8
+ import CollapsedThumbnail from './CardCollapsedThumbnail'
9
+
10
+ interface VideoPreviewProps {
11
+ sourceUrl?: string
12
+ thumbnailUrl?: string
13
+ mimeType: string
14
+ }
15
+
16
+ const VideoPreview: React.FC<VideoPreviewProps> = (props) => {
17
+ const { sourceUrl, thumbnailUrl, mimeType } = props
18
+ const [expanded, setExpanded] = useState(false)
19
+
20
+ const collapse = () => {
21
+ setExpanded(false)
22
+ }
23
+
24
+ if (!sourceUrl) {
25
+ return (
26
+ <CollapsedThumbnail thumbnailUrl={thumbnailUrl} mimeType={mimeType} />
27
+ )
28
+ }
29
+
30
+ return (
31
+ <div
32
+ className={classNames('relative overflow-hidden', {
33
+ 'aspect-video': !expanded,
34
+ })}
35
+ >
36
+ <MediaPlayer
37
+ source={sourceUrl}
38
+ mimeType={mimeType}
39
+ poster={thumbnailUrl}
40
+ playing={expanded}
41
+ loop={true}
42
+ controls={false}
43
+ muted={true}
44
+ showProgress={true}
45
+ onContainerClick={collapse}
46
+ />
47
+ {!expanded && (
48
+ <button
49
+ type="button"
50
+ className="absolute inset-0 block cursor-pointer border-0 p-0 text-left appearance-none"
51
+ onClick={() => setExpanded(true)}
52
+ aria-label="Expand video preview"
53
+ >
54
+ {thumbnailUrl ? (
55
+ <img
56
+ src={thumbnailUrl}
57
+ alt=""
58
+ className="absolute inset-0 h-full w-full object-cover"
59
+ />
60
+ ) : (
61
+ <div className="absolute inset-0 flex items-center justify-center">
62
+ {renderTypeIcon(mimeType, {
63
+ className: 'size-12 text-black/20',
64
+ weight: 'regular',
65
+ })}
66
+ </div>
67
+ )}
68
+ <div className="pointer-events-none absolute left-3 top-3 flex size-8 items-center justify-center rounded-full bg-black/60 text-white">
69
+ <EyeSlashIcon className="size-4" weight="fill" />
70
+ </div>
71
+ </button>
72
+ )}
73
+ {expanded && <CloseButton onClose={collapse} />}
74
+ </div>
75
+ )
76
+ }
77
+
78
+ const CloseButton: React.FC<{ onClose: () => void }> = ({ onClose }) => {
79
+ return (
80
+ <button
81
+ type="button"
82
+ onClick={onClose}
83
+ className="absolute left-3 top-3 z-40 flex size-8 items-center justify-center rounded-full bg-black/60 text-white"
84
+ aria-label="Close preview"
85
+ >
86
+ <EyeIcon className="size-4" weight="fill" />
87
+ </button>
88
+ )
89
+ }
90
+
91
+ export default VideoPreview
@@ -0,0 +1,2 @@
1
+ export { default as CreatorCard } from './Card'
2
+ export type { CreatorCardProps } from './Card'
@@ -0,0 +1,186 @@
1
+ import {
2
+ CheckCircleIcon,
3
+ LockOpenIcon,
4
+ LockSimpleIcon,
5
+ } from '@phosphor-icons/react'
6
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
7
+
8
+ import type {
9
+ LockedAttachmentBaseProps,
10
+ LockedAttachmentSource,
11
+ PaymentStatus,
12
+ } from '../../types'
13
+ import { renderTypeIcon } from '../../utils/icons'
14
+ import { getSourceType } from '../../utils/mimeType'
15
+
16
+ import CardActions from './CardActions'
17
+ import ImagePreview from './CardImagePreview'
18
+ import MediaPreview from './CardMediaPreview'
19
+ import ThumbnailPreview from './CardThumbnailPreview'
20
+
21
+ export interface VisitorCardProps extends LockedAttachmentBaseProps {
22
+ /**
23
+ * Called when the visitor clicks Unlock on an unpaid attachment.
24
+ * Use this to open a checkout flow. Omit to hide the Unlock button.
25
+ */
26
+ onUnlockClick?: () => void
27
+ /**
28
+ * Called to fetch the attachment source — fired automatically when
29
+ * paymentStatus transitions to 'paid', or immediately on click when
30
+ * paymentStatus is already 'paid'. Return a LockedAttachmentSource to
31
+ * unlock the card.
32
+ */
33
+ onFetchSource?: () => Promise<LockedAttachmentSource | void>
34
+ /**
35
+ * Called when the visitor clicks Download on an unlocked card.
36
+ * Omit to hide the Download button.
37
+ */
38
+ onDownloadClick?: () => void
39
+ /**
40
+ * When true, shows loading dots on the Unlock button.
41
+ * Driven by the LockedAttachmentContext (e.g. checkout in progress, payment processing).
42
+ */
43
+ isUnlocking?: boolean
44
+ }
45
+
46
+ function getLockIcon(paymentStatus?: PaymentStatus): React.ElementType {
47
+ return paymentStatus === 'paid' ? LockOpenIcon : LockSimpleIcon
48
+ }
49
+
50
+ const VisitorCard: React.FC<VisitorCardProps> = ({
51
+ title,
52
+ amountText,
53
+ thumbnailUrl,
54
+ mimeType = 'application/octet-stream',
55
+ detail,
56
+ onUnlockClick,
57
+ onFetchSource,
58
+ onDownloadClick,
59
+ paymentStatus,
60
+ isUnlocking = false,
61
+ }) => {
62
+ const [source, setSource] = useState<LockedAttachmentSource | undefined>()
63
+ const hasMounted = useRef(false)
64
+ const fetchingRef = useRef(false)
65
+ // Stable ref so fetchSource doesn't change identity when onFetchSource prop changes
66
+ const onFetchSourceRef = useRef(onFetchSource)
67
+ onFetchSourceRef.current = onFetchSource
68
+
69
+ const isLocked = source === undefined
70
+ const sourceType = getSourceType(mimeType)
71
+ const LockIcon = isLocked ? getLockIcon(paymentStatus) : undefined
72
+
73
+ const fetchSource = useCallback(async (): Promise<void> => {
74
+ if (fetchingRef.current) return
75
+ fetchingRef.current = true
76
+ try {
77
+ const result = await onFetchSourceRef.current?.()
78
+ if (result) setSource(result)
79
+ } finally {
80
+ fetchingRef.current = false
81
+ }
82
+ }, []) // stable — reads onFetchSource via ref
83
+
84
+ // When paymentStatus transitions to 'paid' (e.g. after checkout completes),
85
+ // automatically fetch the source. Skipped on mount.
86
+ useEffect(() => {
87
+ if (!hasMounted.current) {
88
+ hasMounted.current = true
89
+ return
90
+ }
91
+
92
+ if (paymentStatus === 'paid') {
93
+ void fetchSource()
94
+ }
95
+ }, [paymentStatus, fetchSource])
96
+
97
+ const handleUnlockClick = useCallback(() => {
98
+ if (paymentStatus === 'paid') {
99
+ void fetchSource()
100
+ } else {
101
+ onUnlockClick?.()
102
+ }
103
+ }, [paymentStatus, onUnlockClick, fetchSource])
104
+
105
+ let mediaPreview: React.ReactNode
106
+ if (sourceType === 'image') {
107
+ mediaPreview = (
108
+ <ImagePreview
109
+ key={source?.sourceUrl}
110
+ sourceUrl={source?.sourceUrl}
111
+ thumbnailUrl={thumbnailUrl}
112
+ mimeType={mimeType}
113
+ title={title}
114
+ LockIcon={LockIcon}
115
+ />
116
+ )
117
+ } else if (sourceType === 'document') {
118
+ mediaPreview = (
119
+ <ThumbnailPreview
120
+ key={source?.sourceUrl}
121
+ thumbnailUrl={thumbnailUrl}
122
+ mimeType={mimeType}
123
+ LockIcon={LockIcon}
124
+ />
125
+ )
126
+ } else {
127
+ mediaPreview = (
128
+ <MediaPreview
129
+ key={source?.sourceUrl}
130
+ sourceUrl={source?.sourceUrl}
131
+ thumbnailUrl={thumbnailUrl}
132
+ mimeType={mimeType}
133
+ LockIcon={LockIcon}
134
+ />
135
+ )
136
+ }
137
+
138
+ return (
139
+ <div className="w-[280px] select-none overflow-hidden rounded-[24px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_4px_8px_rgba(0,0,0,0.06)]">
140
+ {mediaPreview}
141
+ <div className="px-4 pb-3 pt-3">
142
+ <p className="mb-1.5 truncate text-base font-medium text-black">
143
+ {title}
144
+ </p>
145
+ <div className="flex items-center gap-1">
146
+ {renderTypeIcon(mimeType, {
147
+ className: 'size-5 shrink-0 text-black/55',
148
+ weight: 'regular',
149
+ })}
150
+ {detail != null ? (
151
+ <span className="text-xs font-medium text-black/55">{detail}</span>
152
+ ) : null}
153
+ {paymentStatus === 'paid' ? (
154
+ <>
155
+ <span className="text-xs font-medium text-black/55">•</span>
156
+ <span className="text-xs font-medium text-[#008236]">
157
+ Purchased
158
+ </span>
159
+ <CheckCircleIcon
160
+ className="size-4 text-[#008236]"
161
+ weight="bold"
162
+ />
163
+ </>
164
+ ) : amountText != null ? (
165
+ <>
166
+ <span className="text-xs font-medium text-black/55">•</span>
167
+ <span className="text-xs font-medium text-black/55">
168
+ {amountText}
169
+ </span>
170
+ </>
171
+ ) : null}
172
+ </div>
173
+ <CardActions
174
+ isLocked={isLocked}
175
+ isUnlocking={isUnlocking}
176
+ sourceUrl={source?.sourceUrl}
177
+ redeemUrl={source?.redeemUrl}
178
+ onUnlockClicked={handleUnlockClick}
179
+ onDownloadClicked={onDownloadClick}
180
+ />
181
+ </div>
182
+ </div>
183
+ )
184
+ }
185
+
186
+ export default VisitorCard
@@ -0,0 +1,71 @@
1
+ import { DownloadSimpleIcon, LockSimpleIcon } from '@phosphor-icons/react'
2
+ import React from 'react'
3
+
4
+ interface CardActionsProps {
5
+ isLocked: boolean
6
+ isUnlocking?: boolean
7
+ sourceUrl?: string
8
+ redeemUrl?: string
9
+ onUnlockClicked?: () => void
10
+ onDownloadClicked?: () => void
11
+ }
12
+
13
+ const CardActions: React.FC<CardActionsProps> = (props) => {
14
+ const {
15
+ isLocked,
16
+ isUnlocking = false,
17
+ sourceUrl,
18
+ redeemUrl,
19
+ onUnlockClicked,
20
+ onDownloadClicked,
21
+ } = props
22
+
23
+ if (isLocked && onUnlockClicked != null) {
24
+ return (
25
+ <button
26
+ type="button"
27
+ onClick={onUnlockClicked}
28
+ disabled={isUnlocking}
29
+ className="mt-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-full bg-[#121110] px-4 text-sm font-medium leading-none text-white hover:bg-[#2a2928] disabled:opacity-70"
30
+ >
31
+ {isUnlocking ? (
32
+ <LoadingDots />
33
+ ) : (
34
+ <React.Fragment>
35
+ <LockSimpleIcon className="size-4" weight="fill" />
36
+ Unlock
37
+ </React.Fragment>
38
+ )}
39
+ </button>
40
+ )
41
+ }
42
+
43
+ if (!isLocked && onDownloadClicked != null && sourceUrl != null) {
44
+ return (
45
+ <a
46
+ href={redeemUrl ?? sourceUrl}
47
+ target="_blank"
48
+ rel="noopener noreferrer"
49
+ onClick={onDownloadClicked}
50
+ className="mt-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-full bg-[#121110] px-4 text-sm font-medium leading-none !text-white hover:bg-[#2a2928]"
51
+ >
52
+ <DownloadSimpleIcon className="size-4" weight="bold" />
53
+ Download
54
+ </a>
55
+ )
56
+ }
57
+
58
+ return null
59
+ }
60
+
61
+ const LoadingDots: React.FC = () => {
62
+ return (
63
+ <span className="flex items-center gap-1">
64
+ <span className="size-1 rounded-full bg-white animate-bounce [animation-delay:-0.3s]" />
65
+ <span className="size-1 rounded-full bg-white animate-bounce [animation-delay:-0.15s]" />
66
+ <span className="size-1 rounded-full bg-white animate-bounce" />
67
+ </span>
68
+ )
69
+ }
70
+
71
+ export default CardActions
@@ -0,0 +1,39 @@
1
+ import React, { useState } from 'react'
2
+
3
+ import ThumbnailPreview from './CardThumbnailPreview'
4
+
5
+ interface ImagePreviewProps {
6
+ sourceUrl?: string
7
+ thumbnailUrl?: string
8
+ mimeType: string
9
+ title?: string
10
+ LockIcon?: React.ElementType
11
+ }
12
+
13
+ const ImagePreview: React.FC<ImagePreviewProps> = (props) => {
14
+ const { sourceUrl, thumbnailUrl, mimeType, title, LockIcon } = props
15
+ const [sourceReady, setSourceReady] = useState(false)
16
+
17
+ if (LockIcon != null) {
18
+ return (
19
+ <ThumbnailPreview
20
+ thumbnailUrl={thumbnailUrl}
21
+ mimeType={mimeType}
22
+ LockIcon={LockIcon}
23
+ />
24
+ )
25
+ }
26
+
27
+ return (
28
+ <div className="relative overflow-hidden bg-black/5">
29
+ <img
30
+ src={sourceUrl}
31
+ alt={title}
32
+ className={`block w-full transition-opacity duration-300 ${sourceReady ? 'opacity-100' : 'opacity-0'}`}
33
+ onLoad={() => setSourceReady(true)}
34
+ />
35
+ </div>
36
+ )
37
+ }
38
+
39
+ export default ImagePreview