@boyernick/standard-ui-react 0.1.1-canary.8 → 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.
- package/package.json +5 -3
- package/src/accordion.tsx +5 -2
- package/src/alert-dialog.tsx +1 -1
- package/src/attachment.tsx +5 -2
- package/src/autocomplete.tsx +4 -3
- package/src/block-editor.tsx +1747 -0
- package/src/brand.tsx +2 -2
- package/src/breadcrumb.tsx +14 -7
- package/src/button.tsx +22 -11
- package/src/calendar.tsx +6 -3
- package/src/carousel.tsx +220 -53
- package/src/checkbox.tsx +3 -2
- package/src/code-block.tsx +66 -5
- package/src/collapsible.tsx +4 -2
- package/src/combobox.tsx +4 -3
- package/src/command.tsx +41 -13
- package/src/date-picker.tsx +7 -2
- package/src/dialog.tsx +33 -10
- package/src/empty.tsx +4 -4
- package/src/field.tsx +4 -3
- package/src/filter-group.tsx +143 -0
- package/src/icons.tsx +28 -1
- package/src/illustrations.tsx +219 -141
- package/src/index.ts +245 -18
- package/src/input.tsx +8 -5
- package/src/kbd.tsx +49 -0
- package/src/lib/focus.ts +41 -0
- package/src/lib/popup.ts +1 -1
- package/src/lifeline/company-icon.tsx +71 -0
- package/src/lifeline/index.ts +42 -0
- package/src/lifeline/lifeline-data.ts +97 -0
- package/src/lifeline/lifeline-desktop.tsx +204 -0
- package/src/lifeline/lifeline-event.tsx +108 -0
- package/src/lifeline/lifeline-fireworks.tsx +302 -0
- package/src/lifeline/lifeline-hover-image.tsx +269 -0
- package/src/lifeline/lifeline-icons.tsx +39 -0
- package/src/lifeline/lifeline-intro-timing.ts +105 -0
- package/src/lifeline/lifeline-labels.tsx +24 -0
- package/src/lifeline/lifeline-layout.ts +1 -0
- package/src/lifeline/lifeline-legend.tsx +31 -0
- package/src/lifeline/lifeline-lightbox.tsx +309 -0
- package/src/lifeline/lifeline-marker.tsx +183 -0
- package/src/lifeline/lifeline-people.tsx +99 -0
- package/src/lifeline/lifeline-photos.tsx +366 -0
- package/src/lifeline/lifeline-shell.tsx +135 -0
- package/src/lifeline/lifeline-utils.ts +83 -0
- package/src/lifeline/lifeline-vertical.tsx +482 -0
- package/src/lifeline/lifeline.tsx +69 -0
- package/src/lifeline/types.ts +112 -0
- package/src/lifeline/use-lifeline-intro.ts +130 -0
- package/src/lifeline/use-lifeline-scroll.ts +1011 -0
- package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
- package/src/menubar.tsx +9 -2
- package/src/minimap.tsx +296 -0
- package/src/modal.tsx +608 -0
- package/src/navigation-menu.tsx +338 -67
- package/src/number-field.tsx +336 -61
- package/src/otp-field.tsx +4 -3
- package/src/pagination.tsx +262 -42
- package/src/password-protection.tsx +345 -0
- package/src/popover.tsx +1 -1
- package/src/progress.tsx +5 -0
- package/src/questionnaire.tsx +284 -0
- package/src/radio.tsx +4 -2
- package/src/scroll-area.tsx +4 -1
- package/src/select.tsx +5 -2
- package/src/sidebar.tsx +113 -28
- package/src/slider.tsx +66 -6
- package/src/sounds.tsx +6 -10
- package/src/spinner.tsx +105 -32
- package/src/switch.tsx +4 -1
- package/src/table.tsx +82 -15
- package/src/tabs.tsx +189 -38
- package/src/text-animate.tsx +71 -28
- package/src/textarea.tsx +6 -1
- package/src/timeline.tsx +378 -0
- package/src/toast.tsx +214 -35
- package/src/toggle.tsx +4 -2
- package/src/toolbar.tsx +12 -7
- package/src/tooltip.tsx +43 -3
- package/src/video-player.tsx +396 -127
- package/src/image-modal.tsx +0 -110
- package/src/markdown-editor.tsx +0 -302
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export { Lifeline } from "./lifeline"
|
|
2
|
+
export { LifelineMarkerColumn } from "./lifeline-marker"
|
|
3
|
+
export { useLifelineScroll } from "./use-lifeline-scroll"
|
|
4
|
+
export {
|
|
5
|
+
CompanyIcon,
|
|
6
|
+
registerCompanyIcons,
|
|
7
|
+
type CompanyIconEntry,
|
|
8
|
+
type CompanyIconId,
|
|
9
|
+
} from "./company-icon"
|
|
10
|
+
export { LifelineLegend } from "./lifeline-legend"
|
|
11
|
+
export {
|
|
12
|
+
LifelineShell,
|
|
13
|
+
LifelineNav,
|
|
14
|
+
LifelineStage,
|
|
15
|
+
LifelineFooter,
|
|
16
|
+
} from "./lifeline-shell"
|
|
17
|
+
export {
|
|
18
|
+
defineLifeline,
|
|
19
|
+
localizeLifelineMarkers,
|
|
20
|
+
LIFELINE_CURRENT_YEAR,
|
|
21
|
+
type LifelineBirthday,
|
|
22
|
+
type LifelineMilestone,
|
|
23
|
+
type LifelineMilestones,
|
|
24
|
+
type LifelineRecord,
|
|
25
|
+
type LifelineTextOverrides,
|
|
26
|
+
} from "./lifeline-data"
|
|
27
|
+
|
|
28
|
+
export type {
|
|
29
|
+
LifelineCompany,
|
|
30
|
+
LifelineEvent,
|
|
31
|
+
LifelineEventEffect,
|
|
32
|
+
LifelineEventImage,
|
|
33
|
+
LifelineEventObject,
|
|
34
|
+
LifelineEventSegment,
|
|
35
|
+
LifelineLegendItem,
|
|
36
|
+
LifelineMarker,
|
|
37
|
+
LifelineMetPerson,
|
|
38
|
+
LifelineMentor,
|
|
39
|
+
LifelineMode,
|
|
40
|
+
LifelinePhoto,
|
|
41
|
+
LifelineProps,
|
|
42
|
+
} from "./types"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LifelineEvent,
|
|
3
|
+
LifelineLegendItem,
|
|
4
|
+
LifelineMarker,
|
|
5
|
+
} from "./types"
|
|
6
|
+
|
|
7
|
+
export const LIFELINE_CURRENT_YEAR = 2026
|
|
8
|
+
|
|
9
|
+
export type LifelineMilestone = Omit<LifelineMarker, "year">
|
|
10
|
+
export type LifelineMilestones = Record<number, LifelineMilestone>
|
|
11
|
+
|
|
12
|
+
export interface LifelineBirthday {
|
|
13
|
+
month: number
|
|
14
|
+
day: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LifelineRecord {
|
|
18
|
+
slug: string
|
|
19
|
+
name: string
|
|
20
|
+
birthYear: number
|
|
21
|
+
birthday?: LifelineBirthday
|
|
22
|
+
/** Last year on the timeline. Omit for living people. */
|
|
23
|
+
endYear?: number
|
|
24
|
+
description: string
|
|
25
|
+
/** People-legend labels; defaults to Mentors / Met in person. */
|
|
26
|
+
legend?: LifelineLegendItem[]
|
|
27
|
+
markers: LifelineMarker[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface DefineLifelineInput {
|
|
31
|
+
slug: string
|
|
32
|
+
name: string
|
|
33
|
+
birthYear: number
|
|
34
|
+
birthday?: LifelineBirthday
|
|
35
|
+
endYear?: number
|
|
36
|
+
description: string
|
|
37
|
+
legend?: LifelineLegendItem[]
|
|
38
|
+
milestones: LifelineMilestones
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Translated event texts keyed by year, aligned by index with the
|
|
43
|
+
* source milestone's events. Only the text is swapped — images,
|
|
44
|
+
* effects, mentors, and structure stay single-sourced.
|
|
45
|
+
*/
|
|
46
|
+
export type LifelineTextOverrides = Record<number, string[]>
|
|
47
|
+
|
|
48
|
+
export function localizeLifelineMarkers(
|
|
49
|
+
markers: LifelineMarker[],
|
|
50
|
+
texts: LifelineTextOverrides,
|
|
51
|
+
): LifelineMarker[] {
|
|
52
|
+
return markers.map((marker) => {
|
|
53
|
+
const translated = texts[marker.year]
|
|
54
|
+
if (!translated) return marker
|
|
55
|
+
|
|
56
|
+
const events: LifelineEvent[] = marker.events.map((event, index) => {
|
|
57
|
+
const text = translated[index]
|
|
58
|
+
if (text === undefined) return event
|
|
59
|
+
if (typeof event === "string") return text
|
|
60
|
+
if (Array.isArray(event)) return event
|
|
61
|
+
return { ...event, text }
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
return { ...marker, events }
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function defineLifeline(input: DefineLifelineInput): LifelineRecord {
|
|
69
|
+
const { milestones, ...record } = input
|
|
70
|
+
const lastYear = input.endYear ?? LIFELINE_CURRENT_YEAR
|
|
71
|
+
const markers: LifelineMarker[] = []
|
|
72
|
+
const today = new Date()
|
|
73
|
+
|
|
74
|
+
for (let year = input.birthYear; year <= lastYear; year++) {
|
|
75
|
+
const milestone = milestones[year]
|
|
76
|
+
const birthdayPending =
|
|
77
|
+
year === LIFELINE_CURRENT_YEAR &&
|
|
78
|
+
input.birthday &&
|
|
79
|
+
(today.getMonth() + 1 < input.birthday.month ||
|
|
80
|
+
(today.getMonth() + 1 === input.birthday.month &&
|
|
81
|
+
today.getDate() < input.birthday.day))
|
|
82
|
+
const age = birthdayPending ? year - input.birthYear - 1 : undefined
|
|
83
|
+
|
|
84
|
+
markers.push(
|
|
85
|
+
milestone
|
|
86
|
+
? { year, ...(age !== undefined && { age }), ...milestone }
|
|
87
|
+
: {
|
|
88
|
+
id: `year-${year}`,
|
|
89
|
+
year,
|
|
90
|
+
events: [],
|
|
91
|
+
...(age !== undefined && { age }),
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { ...record, markers }
|
|
97
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useMemo, type CSSProperties } from "react"
|
|
4
|
+
import { cn } from "../lib/cn"
|
|
5
|
+
import { focusRing, focusRingBorder } from "../lib/focus"
|
|
6
|
+
import {
|
|
7
|
+
LifelineStickyLabels,
|
|
8
|
+
LIFELINE_STICKY_SHIELD_WIDTH,
|
|
9
|
+
} from "./lifeline-labels"
|
|
10
|
+
import { LifelineMarkerColumn } from "./lifeline-marker"
|
|
11
|
+
import type { LifelineEventImage, LifelineProps } from "./types"
|
|
12
|
+
import { getLifelineEventImage } from "./lifeline-event"
|
|
13
|
+
import { LifelineHoverImageProvider } from "./lifeline-hover-image"
|
|
14
|
+
import { LifelineFloatingPhotos } from "./lifeline-photos"
|
|
15
|
+
import { useLifelineIntro } from "./use-lifeline-intro"
|
|
16
|
+
import { useLifelineScroll } from "./use-lifeline-scroll"
|
|
17
|
+
import { getMarkerWidth } from "./lifeline-utils"
|
|
18
|
+
|
|
19
|
+
export function LifelineDesktop({
|
|
20
|
+
markers,
|
|
21
|
+
birthYear,
|
|
22
|
+
className,
|
|
23
|
+
title = "Lifeline",
|
|
24
|
+
mode = "auto",
|
|
25
|
+
}: LifelineProps) {
|
|
26
|
+
const widths = useMemo(
|
|
27
|
+
() =>
|
|
28
|
+
markers.map((marker, index) =>
|
|
29
|
+
getMarkerWidth(marker, markers[index + 1]?.year),
|
|
30
|
+
),
|
|
31
|
+
[markers],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// Left edge of each marker's slot within the track — anchors for the
|
|
35
|
+
// floating photo cards.
|
|
36
|
+
const offsets = useMemo(() => {
|
|
37
|
+
const result: number[] = []
|
|
38
|
+
let sum = 0
|
|
39
|
+
for (const width of widths) {
|
|
40
|
+
result.push(sum)
|
|
41
|
+
sum += width
|
|
42
|
+
}
|
|
43
|
+
return result
|
|
44
|
+
}, [widths])
|
|
45
|
+
|
|
46
|
+
const hoverImages = useMemo(() => {
|
|
47
|
+
const images: LifelineEventImage[] = []
|
|
48
|
+
for (const marker of markers) {
|
|
49
|
+
for (const event of marker.events) {
|
|
50
|
+
const image = getLifelineEventImage(event)
|
|
51
|
+
if (image) images.push(image)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return images
|
|
55
|
+
}, [markers])
|
|
56
|
+
|
|
57
|
+
const intro = useLifelineIntro(widths)
|
|
58
|
+
const isIntroAnimating = intro.shouldPlay && intro.isPlaying
|
|
59
|
+
|
|
60
|
+
const {
|
|
61
|
+
sectionRef,
|
|
62
|
+
trackRef,
|
|
63
|
+
labelsRef,
|
|
64
|
+
setMarkerRef,
|
|
65
|
+
isLayoutReady,
|
|
66
|
+
isEmbed,
|
|
67
|
+
introArmed,
|
|
68
|
+
} = useLifelineScroll(markers.length, {
|
|
69
|
+
mode,
|
|
70
|
+
introLocked: isIntroAnimating,
|
|
71
|
+
introAnimating: isIntroAnimating,
|
|
72
|
+
introSkipped: !intro.shouldPlay,
|
|
73
|
+
introRailMs: intro.railDuration,
|
|
74
|
+
introGetTrackProgress: intro.getTrackProgressAtTime,
|
|
75
|
+
onIntroScrollStart: intro.startIntroTimer,
|
|
76
|
+
onIntroSettleComplete: intro.completeIntro,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// Embedded, the open waits for the module to come into view: the marker
|
|
80
|
+
// fades are CSS animations that start the moment their class lands, so
|
|
81
|
+
// applying it early would spend them below the fold.
|
|
82
|
+
const introWaitingInView = isEmbed && intro.shouldPlay && !introArmed
|
|
83
|
+
const showIntro = isIntroAnimating && isLayoutReady && !introWaitingInView
|
|
84
|
+
|
|
85
|
+
const trackWidth =
|
|
86
|
+
LIFELINE_STICKY_SHIELD_WIDTH + widths.reduce((sum, width) => sum + width, 0)
|
|
87
|
+
|
|
88
|
+
const introStyle = {
|
|
89
|
+
"--lifeline-labels-ms": `${intro.labelsDuration}ms`,
|
|
90
|
+
"--lifeline-rail-ms": `${intro.railDuration}ms`,
|
|
91
|
+
} as CSSProperties
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<section
|
|
95
|
+
ref={sectionRef}
|
|
96
|
+
data-lifeline-mode={isEmbed ? "embed" : "page"}
|
|
97
|
+
// Embedded, the module needs a tab stop to be operable at all — a
|
|
98
|
+
// page-mode lifeline is reached just by scrolling to it.
|
|
99
|
+
tabIndex={isEmbed ? 0 : undefined}
|
|
100
|
+
className={cn(
|
|
101
|
+
"relative h-full min-h-0 select-none overflow-hidden [&_a]:cursor-pointer",
|
|
102
|
+
// `pan-y` lets the browser start a vertical page scroll on the
|
|
103
|
+
// first frame instead of waiting on the JS axis lock; horizontal
|
|
104
|
+
// panning stays ours.
|
|
105
|
+
isEmbed &&
|
|
106
|
+
cn("touch-pan-y", focusRingBorder, focusRing),
|
|
107
|
+
// Hold it blank rather than showing a settled timeline that then
|
|
108
|
+
// resets itself to play the intro. Below the fold there is nothing
|
|
109
|
+
// to see anyway, and the arming margin means it fills in before it
|
|
110
|
+
// reaches the reader.
|
|
111
|
+
(!isLayoutReady || introWaitingInView) && "invisible",
|
|
112
|
+
className,
|
|
113
|
+
)}
|
|
114
|
+
aria-label={title}
|
|
115
|
+
style={showIntro ? introStyle : undefined}
|
|
116
|
+
>
|
|
117
|
+
<LifelineHoverImageProvider preload={hoverImages}>
|
|
118
|
+
{/*
|
|
119
|
+
Centered — but `safe center` where the browser understands it, which
|
|
120
|
+
matters once the height is the consumer's to choose. A track taller
|
|
121
|
+
than its box would otherwise overflow equally top and bottom, and
|
|
122
|
+
since the section clips, the first thing lost is the row nearest the
|
|
123
|
+
top: the Age/Years label column and the year labels. `safe` falls
|
|
124
|
+
back to start-alignment exactly in that case, so the labels and the
|
|
125
|
+
rail stay put and only the tail of a long column clips. Declared
|
|
126
|
+
inline so browsers without it simply keep the `items-center` class.
|
|
127
|
+
*/}
|
|
128
|
+
<div
|
|
129
|
+
className="flex h-full items-center overflow-hidden"
|
|
130
|
+
style={isEmbed ? { alignItems: "safe center" } : undefined}
|
|
131
|
+
>
|
|
132
|
+
<div
|
|
133
|
+
ref={trackRef}
|
|
134
|
+
className="relative flex w-max items-start will-change-transform [--lifeline-people-top:calc(14.5rem+40px)] [--lifeline-rail:5rem]"
|
|
135
|
+
style={{ width: trackWidth }}
|
|
136
|
+
>
|
|
137
|
+
{/*
|
|
138
|
+
LIFELINE_STICKY_SHIELD_WIDTH reserves this column at the head of
|
|
139
|
+
the track, and the column has to actually paint it: once the
|
|
140
|
+
track scrolls, marker text passes underneath and would otherwise
|
|
141
|
+
read straight through "Age" and "Years".
|
|
142
|
+
|
|
143
|
+
`bg-background-primary` to match the framing the shell puts
|
|
144
|
+
around this — reframe the page on a different surface and this
|
|
145
|
+
wants overriding with it. The transition is not decoration
|
|
146
|
+
either: without it the shield snaps between the two while the
|
|
147
|
+
page behind it is still crossfading, which flashes a hard box
|
|
148
|
+
for the length of a theme switch. 300ms on the default curve is
|
|
149
|
+
what `LifelineShell` fades on, so the two move as one.
|
|
150
|
+
*/}
|
|
151
|
+
<div
|
|
152
|
+
ref={labelsRef}
|
|
153
|
+
className="lifeline-labels shrink-0 bg-background-primary transition-colors duration-300 will-change-transform"
|
|
154
|
+
style={{ width: LIFELINE_STICKY_SHIELD_WIDTH }}
|
|
155
|
+
>
|
|
156
|
+
<div className={cn(showIntro && "lifeline-labels-intro")}>
|
|
157
|
+
<LifelineStickyLabels />
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<div className="relative">
|
|
162
|
+
<div
|
|
163
|
+
aria-hidden="true"
|
|
164
|
+
className="pointer-events-none absolute inset-x-0 top-[var(--lifeline-rail)] h-px overflow-hidden"
|
|
165
|
+
>
|
|
166
|
+
<div
|
|
167
|
+
className={cn(
|
|
168
|
+
"h-px w-full border-t border-dashed border-border-primary transition-colors duration-300",
|
|
169
|
+
showIntro && "lifeline-rail-intro",
|
|
170
|
+
)}
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<div className="relative flex items-start">
|
|
175
|
+
{markers.map((marker, index) => (
|
|
176
|
+
<LifelineMarkerColumn
|
|
177
|
+
key={marker.id}
|
|
178
|
+
ref={(node) => setMarkerRef(index, node)}
|
|
179
|
+
marker={marker}
|
|
180
|
+
birthYear={birthYear}
|
|
181
|
+
minWidth={widths[index]}
|
|
182
|
+
animateIntro={showIntro}
|
|
183
|
+
introDelay={intro.getMarkerDelay(index)}
|
|
184
|
+
introDuration={intro.getMarkerFadeDuration(index)}
|
|
185
|
+
/>
|
|
186
|
+
))}
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<LifelineFloatingPhotos
|
|
190
|
+
markers={markers}
|
|
191
|
+
offsets={offsets}
|
|
192
|
+
widths={widths}
|
|
193
|
+
animateIntro={showIntro}
|
|
194
|
+
getIntroDelay={intro.getMarkerDelay}
|
|
195
|
+
getIntroDuration={intro.getMarkerFadeDuration}
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
</LifelineHoverImageProvider>
|
|
202
|
+
</section>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LifelineEvent,
|
|
3
|
+
LifelineEventEffect,
|
|
4
|
+
LifelineEventImage,
|
|
5
|
+
LifelineEventSegment,
|
|
6
|
+
} from "./types"
|
|
7
|
+
|
|
8
|
+
function getEventContent(
|
|
9
|
+
event: LifelineEvent,
|
|
10
|
+
): string | LifelineEventSegment[] {
|
|
11
|
+
if (typeof event === "object" && !Array.isArray(event) && "text" in event) {
|
|
12
|
+
return event.text
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return event
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getLifelineEventImage(
|
|
19
|
+
event: LifelineEvent,
|
|
20
|
+
): LifelineEventImage | undefined {
|
|
21
|
+
if (typeof event === "object" && !Array.isArray(event) && "image" in event) {
|
|
22
|
+
return event.image
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getLifelineEventEffect(
|
|
29
|
+
event: LifelineEvent,
|
|
30
|
+
): LifelineEventEffect | undefined {
|
|
31
|
+
if (typeof event === "object" && !Array.isArray(event) && "effect" in event) {
|
|
32
|
+
return event.effect
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function LifelineEventText({
|
|
39
|
+
event,
|
|
40
|
+
className,
|
|
41
|
+
}: {
|
|
42
|
+
event: LifelineEvent
|
|
43
|
+
className?: string
|
|
44
|
+
}) {
|
|
45
|
+
const content = getEventContent(event)
|
|
46
|
+
|
|
47
|
+
if (typeof content === "string") {
|
|
48
|
+
return <span className={className}>{content}</span>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<span className={className}>
|
|
53
|
+
{content.map((segment, index) =>
|
|
54
|
+
segment.type === "link" ? (
|
|
55
|
+
<a
|
|
56
|
+
key={index}
|
|
57
|
+
href={segment.href}
|
|
58
|
+
target="_blank"
|
|
59
|
+
rel="noopener noreferrer"
|
|
60
|
+
className="text-inherit underline decoration-fg-quaternary underline-offset-2 transition-colors duration-300 group-hover:text-fg-primary group-hover:decoration-fg-tertiary"
|
|
61
|
+
>
|
|
62
|
+
{segment.value}
|
|
63
|
+
</a>
|
|
64
|
+
) : (
|
|
65
|
+
<span key={index}>{segment.value}</span>
|
|
66
|
+
),
|
|
67
|
+
)}
|
|
68
|
+
</span>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Always-visible media embedded in the timeline (image.inline). */
|
|
73
|
+
export function LifelineEventMedia({
|
|
74
|
+
media,
|
|
75
|
+
className,
|
|
76
|
+
}: {
|
|
77
|
+
media: LifelineEventImage
|
|
78
|
+
className?: string
|
|
79
|
+
}) {
|
|
80
|
+
if (media.video) {
|
|
81
|
+
return (
|
|
82
|
+
<video
|
|
83
|
+
src={media.video}
|
|
84
|
+
poster={media.src}
|
|
85
|
+
autoPlay
|
|
86
|
+
muted
|
|
87
|
+
loop
|
|
88
|
+
playsInline
|
|
89
|
+
preload="metadata"
|
|
90
|
+
aria-label={media.alt}
|
|
91
|
+
className={className}
|
|
92
|
+
/>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
98
|
+
<img src={media.src} alt={media.alt} loading="lazy" className={className} />
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getLifelineEventKey(event: LifelineEvent, index: number) {
|
|
103
|
+
const content = getEventContent(event)
|
|
104
|
+
|
|
105
|
+
if (typeof content === "string") return `${index}-${content}`
|
|
106
|
+
|
|
107
|
+
return `${index}-${content.map((segment) => segment.value).join("")}`
|
|
108
|
+
}
|