@boyernick/standard-ui-react 0.1.1-canary.9 → 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 +27 -12
- 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 +238 -18
- package/src/input.tsx +8 -5
- package/src/kbd.tsx +10 -4
- 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,309 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react"
|
|
10
|
+
import { createPortal } from "react-dom"
|
|
11
|
+
import { cn } from "../lib/cn"
|
|
12
|
+
import type { LifelinePhoto } from "./types"
|
|
13
|
+
|
|
14
|
+
const OPEN_MS = 520
|
|
15
|
+
/**
|
|
16
|
+
* Gentle start, soft landing — the quint curve front-loaded nearly all
|
|
17
|
+
* of the travel into the first 120ms, which read as a jump.
|
|
18
|
+
*/
|
|
19
|
+
const EASE = "cubic-bezier(0.32, 0.72, 0, 1)"
|
|
20
|
+
/** Fraction of the viewport the expanded media may occupy. */
|
|
21
|
+
const FIT = 0.85
|
|
22
|
+
|
|
23
|
+
interface Target {
|
|
24
|
+
left: number
|
|
25
|
+
top: number
|
|
26
|
+
width: number
|
|
27
|
+
height: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The card's true geometry at handoff time. Center comes from the
|
|
32
|
+
* bounding box (rotation about center preserves it); width/height are
|
|
33
|
+
* the untransformed layout size (offsetWidth/offsetHeight) — the
|
|
34
|
+
* bounding box of a tilted card is its axis-aligned hull, which is
|
|
35
|
+
* larger than the card and lands the clone visibly off.
|
|
36
|
+
*/
|
|
37
|
+
export interface LifelineLightboxStart {
|
|
38
|
+
cx: number
|
|
39
|
+
cy: number
|
|
40
|
+
w: number
|
|
41
|
+
h: number
|
|
42
|
+
/** Playback position of the card's video, for a seamless swap. */
|
|
43
|
+
mediaTime?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The clone's media. Videos mount paused, pre-seeked to the card's
|
|
48
|
+
* playback position — a static frame the compositor can scale without
|
|
49
|
+
* decoding — and only play once `playing` flips after the open
|
|
50
|
+
* transition settles. Dismissing pauses again for the return flight.
|
|
51
|
+
*/
|
|
52
|
+
function LightboxMedia({
|
|
53
|
+
photo,
|
|
54
|
+
playing,
|
|
55
|
+
mediaTime,
|
|
56
|
+
}: {
|
|
57
|
+
photo: LifelinePhoto
|
|
58
|
+
playing: boolean
|
|
59
|
+
mediaTime?: number
|
|
60
|
+
}) {
|
|
61
|
+
const videoRef = useRef<HTMLVideoElement>(null)
|
|
62
|
+
const seeded = useRef(false)
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const video = videoRef.current
|
|
66
|
+
if (!video) return
|
|
67
|
+
if (playing) {
|
|
68
|
+
// Seek only now, stationary — a seek during the transition
|
|
69
|
+
// decodes a new frame mid-flight and visibly swaps the image.
|
|
70
|
+
if (!seeded.current) {
|
|
71
|
+
seeded.current = true
|
|
72
|
+
if (mediaTime !== undefined) video.currentTime = mediaTime
|
|
73
|
+
}
|
|
74
|
+
video.play().catch(() => {
|
|
75
|
+
// Autoplay rejection just leaves the poster frame showing.
|
|
76
|
+
})
|
|
77
|
+
} else {
|
|
78
|
+
video.pause()
|
|
79
|
+
}
|
|
80
|
+
}, [playing, mediaTime])
|
|
81
|
+
|
|
82
|
+
if (!photo.video) {
|
|
83
|
+
return (
|
|
84
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
85
|
+
<img
|
|
86
|
+
src={photo.src}
|
|
87
|
+
alt={photo.alt}
|
|
88
|
+
className="block h-full w-full object-cover"
|
|
89
|
+
/>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<video
|
|
95
|
+
ref={videoRef}
|
|
96
|
+
src={photo.video}
|
|
97
|
+
poster={photo.src}
|
|
98
|
+
muted
|
|
99
|
+
loop
|
|
100
|
+
playsInline
|
|
101
|
+
preload="auto"
|
|
102
|
+
aria-label={photo.alt}
|
|
103
|
+
className="block h-full w-full object-cover"
|
|
104
|
+
/>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function computeTarget(start: LifelineLightboxStart): Target {
|
|
109
|
+
const vw = window.innerWidth
|
|
110
|
+
const vh = window.innerHeight
|
|
111
|
+
const aspect = start.h / start.w
|
|
112
|
+
const width = Math.min(vw * FIT, (vh * FIT) / aspect)
|
|
113
|
+
const height = width * aspect
|
|
114
|
+
return { left: (vw - width) / 2, top: (vh - height) / 2, width, height }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Expands a floating card's media from its spot on the timeline to the
|
|
119
|
+
* center of the screen and back — a FLIP animation on a fixed clone
|
|
120
|
+
* portaled to <body> (the track is transformed, so fixed positioning
|
|
121
|
+
* inside it would break). The original card stays in layout, hidden,
|
|
122
|
+
* and is re-measured on dismiss so the media returns wherever the card
|
|
123
|
+
* now is, even if the timeline moved.
|
|
124
|
+
*/
|
|
125
|
+
export function LifelineLightbox({
|
|
126
|
+
photo,
|
|
127
|
+
rotate,
|
|
128
|
+
start,
|
|
129
|
+
getHome,
|
|
130
|
+
onClosed,
|
|
131
|
+
}: {
|
|
132
|
+
photo: LifelinePhoto
|
|
133
|
+
/** The card's resting tilt — animated away as the media centers. */
|
|
134
|
+
rotate: number
|
|
135
|
+
/** The card's geometry at click time. */
|
|
136
|
+
start: LifelineLightboxStart
|
|
137
|
+
/** Re-measures the card at dismiss time. */
|
|
138
|
+
getHome: () => LifelineLightboxStart | null
|
|
139
|
+
onClosed: () => void
|
|
140
|
+
}) {
|
|
141
|
+
const target = useRef<Target | null>(null)
|
|
142
|
+
if (target.current === null) target.current = computeTarget(start)
|
|
143
|
+
const { left, top, width, height } = target.current
|
|
144
|
+
|
|
145
|
+
const figureRef = useRef<HTMLElement>(null)
|
|
146
|
+
|
|
147
|
+
const reduceMotion = useRef(
|
|
148
|
+
typeof window !== "undefined" &&
|
|
149
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
|
150
|
+
).current
|
|
151
|
+
|
|
152
|
+
// Center-anchored FLIP: rotation and scale about the center match
|
|
153
|
+
// how the card itself is transformed, so the first frame is
|
|
154
|
+
// pixel-identical to the card underneath.
|
|
155
|
+
const toTransform = useCallback(
|
|
156
|
+
(home: LifelineLightboxStart) =>
|
|
157
|
+
`translate(${home.cx - (left + width / 2)}px, ${
|
|
158
|
+
home.cy - (top + height / 2)
|
|
159
|
+
}px) scale(${home.w / width}) rotate(${rotate}deg)`,
|
|
160
|
+
[left, top, width, height, rotate],
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const [entered, setEntered] = useState(reduceMotion)
|
|
164
|
+
const [transform, setTransform] = useState(() =>
|
|
165
|
+
reduceMotion ? "none" : toTransform(start),
|
|
166
|
+
)
|
|
167
|
+
const closing = useRef(false)
|
|
168
|
+
// Playback waits for the open transition to finish — a playing
|
|
169
|
+
// video decodes frames while the transform animates and drops
|
|
170
|
+
// transition frames on mobile; a paused, pre-seeked frame is a
|
|
171
|
+
// static layer and animates cheaply.
|
|
172
|
+
const [settled, setSettled] = useState(reduceMotion)
|
|
173
|
+
|
|
174
|
+
// Safety net if transitionend never fires for the open.
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (reduceMotion) return
|
|
177
|
+
const timeout = window.setTimeout(() => {
|
|
178
|
+
if (!closing.current) setSettled(true)
|
|
179
|
+
}, OPEN_MS + 80)
|
|
180
|
+
return () => window.clearTimeout(timeout)
|
|
181
|
+
}, [reduceMotion])
|
|
182
|
+
|
|
183
|
+
// FLIP: first paint sits over the card, next frame eases to center.
|
|
184
|
+
useLayoutEffect(() => {
|
|
185
|
+
if (reduceMotion) return
|
|
186
|
+
let inner = 0
|
|
187
|
+
const outer = requestAnimationFrame(() => {
|
|
188
|
+
inner = requestAnimationFrame(() => {
|
|
189
|
+
setEntered(true)
|
|
190
|
+
setTransform("translate(0px, 0px) scale(1) rotate(0deg)")
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
return () => {
|
|
194
|
+
cancelAnimationFrame(outer)
|
|
195
|
+
cancelAnimationFrame(inner)
|
|
196
|
+
}
|
|
197
|
+
}, [reduceMotion])
|
|
198
|
+
|
|
199
|
+
// No gesture may pan while the lightbox is up — iOS otherwise
|
|
200
|
+
// rubber-bands the body behind the fixed overlay and can leave the
|
|
201
|
+
// whole page stuck offset after dismiss. React's root touch
|
|
202
|
+
// listeners are passive, so this needs a native non-passive one.
|
|
203
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
const root = rootRef.current
|
|
206
|
+
if (!root) return
|
|
207
|
+
const block = (event: TouchEvent) => event.preventDefault()
|
|
208
|
+
root.addEventListener("touchmove", block, { passive: false })
|
|
209
|
+
return () => root.removeEventListener("touchmove", block)
|
|
210
|
+
}, [])
|
|
211
|
+
|
|
212
|
+
// The press that opens the card dispatches one more click right
|
|
213
|
+
// after pointerup — by then the clone is mounted underneath the
|
|
214
|
+
// pointer and would dismiss itself. Swallow exactly that click;
|
|
215
|
+
// every later click dismisses normally.
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
const swallow = (event: MouseEvent) => {
|
|
218
|
+
event.stopPropagation()
|
|
219
|
+
event.preventDefault()
|
|
220
|
+
}
|
|
221
|
+
window.addEventListener("click", swallow, { capture: true, once: true })
|
|
222
|
+
const timeout = window.setTimeout(() => {
|
|
223
|
+
window.removeEventListener("click", swallow, { capture: true })
|
|
224
|
+
}, 500)
|
|
225
|
+
return () => {
|
|
226
|
+
window.clearTimeout(timeout)
|
|
227
|
+
window.removeEventListener("click", swallow, { capture: true })
|
|
228
|
+
}
|
|
229
|
+
}, [])
|
|
230
|
+
|
|
231
|
+
const dismiss = useCallback(() => {
|
|
232
|
+
if (closing.current) return
|
|
233
|
+
closing.current = true
|
|
234
|
+
if (reduceMotion) {
|
|
235
|
+
onClosed()
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
setSettled(false) // freeze the video so the return flight is cheap
|
|
239
|
+
setEntered(false)
|
|
240
|
+
setTransform(toTransform(getHome() ?? start))
|
|
241
|
+
// transitionend is the primary signal; this is the safety net.
|
|
242
|
+
window.setTimeout(onClosed, OPEN_MS + 120)
|
|
243
|
+
}, [reduceMotion, onClosed, toTransform, getHome, start])
|
|
244
|
+
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
247
|
+
if (event.key === "Escape") dismiss()
|
|
248
|
+
}
|
|
249
|
+
window.addEventListener("keydown", onKeyDown)
|
|
250
|
+
return () => window.removeEventListener("keydown", onKeyDown)
|
|
251
|
+
}, [dismiss])
|
|
252
|
+
|
|
253
|
+
return createPortal(
|
|
254
|
+
<div
|
|
255
|
+
ref={rootRef}
|
|
256
|
+
className="fixed inset-0 z-[999] touch-none overscroll-contain"
|
|
257
|
+
role="dialog"
|
|
258
|
+
aria-modal="true"
|
|
259
|
+
aria-label={photo.alt}
|
|
260
|
+
// The portal lives under <body>, but React events still bubble up
|
|
261
|
+
// the component tree — without this, a backdrop press would reach
|
|
262
|
+
// the card's drag handlers and the track's scrubber.
|
|
263
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
264
|
+
onPointerMove={(event) => event.stopPropagation()}
|
|
265
|
+
onPointerUp={(event) => event.stopPropagation()}
|
|
266
|
+
onClick={(event) => event.stopPropagation()}
|
|
267
|
+
>
|
|
268
|
+
<div
|
|
269
|
+
className={cn(
|
|
270
|
+
"absolute inset-0 cursor-zoom-out bg-black/70 transition-opacity",
|
|
271
|
+
entered ? "opacity-100" : "opacity-0",
|
|
272
|
+
)}
|
|
273
|
+
// Synced to the media's travel — a faster fade left the clone
|
|
274
|
+
// flying over an undimmed page at the end of the dismiss.
|
|
275
|
+
style={{ transitionDuration: `${OPEN_MS}ms` }}
|
|
276
|
+
onClick={dismiss}
|
|
277
|
+
/>
|
|
278
|
+
<figure
|
|
279
|
+
ref={figureRef}
|
|
280
|
+
className="absolute cursor-zoom-out overflow-hidden rounded-xl shadow-2xl ring-1 ring-border-primary"
|
|
281
|
+
style={{
|
|
282
|
+
left,
|
|
283
|
+
top,
|
|
284
|
+
width,
|
|
285
|
+
height,
|
|
286
|
+
transform,
|
|
287
|
+
transformOrigin: "center",
|
|
288
|
+
transition: reduceMotion ? undefined : `transform ${OPEN_MS}ms ${EASE}`,
|
|
289
|
+
// Promoted for the whole flight — mobile otherwise
|
|
290
|
+
// re-rasterizes the shadowed, corner-clipped media mid-scale.
|
|
291
|
+
willChange: "transform",
|
|
292
|
+
}}
|
|
293
|
+
onClick={dismiss}
|
|
294
|
+
onTransitionEnd={(event) => {
|
|
295
|
+
if (event.propertyName !== "transform") return
|
|
296
|
+
if (closing.current) onClosed()
|
|
297
|
+
else setSettled(true)
|
|
298
|
+
}}
|
|
299
|
+
>
|
|
300
|
+
<LightboxMedia
|
|
301
|
+
photo={photo}
|
|
302
|
+
playing={settled}
|
|
303
|
+
mediaTime={start.mediaTime}
|
|
304
|
+
/>
|
|
305
|
+
</figure>
|
|
306
|
+
</div>,
|
|
307
|
+
document.body,
|
|
308
|
+
)
|
|
309
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type CSSProperties } from "react"
|
|
4
|
+
import { LifelineFilmIcon as Film, LifelineImageIcon as ImageIcon } from "./lifeline-icons"
|
|
5
|
+
import { cn } from "../lib/cn"
|
|
6
|
+
import { CompanyIcon } from "./company-icon"
|
|
7
|
+
import {
|
|
8
|
+
getLifelineEventEffect,
|
|
9
|
+
getLifelineEventImage,
|
|
10
|
+
getLifelineEventKey,
|
|
11
|
+
LifelineEventText,
|
|
12
|
+
} from "./lifeline-event"
|
|
13
|
+
import { useLifelineFireworks } from "./lifeline-fireworks"
|
|
14
|
+
import { useLifelineHoverImage } from "./lifeline-hover-image"
|
|
15
|
+
import { aggregateLifelinePeople, LifelinePeople } from "./lifeline-people"
|
|
16
|
+
import type { LifelineMarker } from "./types"
|
|
17
|
+
|
|
18
|
+
interface LifelineMarkerColumnProps {
|
|
19
|
+
marker: LifelineMarker
|
|
20
|
+
birthYear: number
|
|
21
|
+
minWidth: number
|
|
22
|
+
animateIntro?: boolean
|
|
23
|
+
introDelay?: number
|
|
24
|
+
introDuration?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const LifelineMarkerColumn = forwardRef<
|
|
28
|
+
HTMLDivElement,
|
|
29
|
+
LifelineMarkerColumnProps
|
|
30
|
+
>(function LifelineMarkerColumn(
|
|
31
|
+
{
|
|
32
|
+
marker,
|
|
33
|
+
birthYear,
|
|
34
|
+
minWidth,
|
|
35
|
+
animateIntro = false,
|
|
36
|
+
introDelay = 0,
|
|
37
|
+
introDuration = 420,
|
|
38
|
+
},
|
|
39
|
+
ref,
|
|
40
|
+
) {
|
|
41
|
+
const age = marker.age ?? marker.year - birthYear
|
|
42
|
+
const people = aggregateLifelinePeople(marker)
|
|
43
|
+
const hoverImage = useLifelineHoverImage()
|
|
44
|
+
const fireworks = useLifelineFireworks()
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
ref={ref}
|
|
49
|
+
className="group relative shrink-0 pr-8 transition-opacity duration-300 ease-out will-change-opacity"
|
|
50
|
+
style={{ width: minWidth }}
|
|
51
|
+
aria-label={marker.label ?? `${marker.year}`}
|
|
52
|
+
>
|
|
53
|
+
<div
|
|
54
|
+
className={cn("relative", animateIntro && "lifeline-marker-intro")}
|
|
55
|
+
style={{
|
|
56
|
+
animationDelay: animateIntro ? `${introDelay}ms` : undefined,
|
|
57
|
+
...(animateIntro
|
|
58
|
+
? ({
|
|
59
|
+
"--lifeline-marker-fade-ms": `${introDuration}ms`,
|
|
60
|
+
} as CSSProperties)
|
|
61
|
+
: {}),
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<span
|
|
65
|
+
className="absolute left-0 top-[var(--lifeline-rail)] z-10 h-[10px] w-px -translate-y-1/2 bg-fg-quaternary transition-colors duration-300 group-hover:bg-fg-tertiary"
|
|
66
|
+
aria-hidden="true"
|
|
67
|
+
/>
|
|
68
|
+
|
|
69
|
+
<div className="flex w-full flex-col items-start text-left">
|
|
70
|
+
<p className="mb-5 h-4 text-[11px] font-medium leading-4 tabular-nums text-fg-tertiary transition-colors duration-300 group-hover:text-fg-primary">
|
|
71
|
+
{age}
|
|
72
|
+
</p>
|
|
73
|
+
|
|
74
|
+
<p className="mb-6 h-5 whitespace-nowrap text-[15px] font-medium leading-5 tabular-nums text-fg-secondary transition-colors duration-300 group-hover:text-fg-primary">
|
|
75
|
+
{marker.label ?? marker.year}
|
|
76
|
+
</p>
|
|
77
|
+
|
|
78
|
+
<div className="relative w-full pb-10 text-fg-secondary transition-colors duration-300 group-hover:text-fg-primary">
|
|
79
|
+
{/* When this column carries people, the content block reserves
|
|
80
|
+
the band's height as a floor: short and average columns put
|
|
81
|
+
their portraits on the same line as every other column, and
|
|
82
|
+
a column whose events run past the floor pushes its own
|
|
83
|
+
portraits below them instead of under them. pb-6 is the gap
|
|
84
|
+
in the overflow case — absorbed by the floor otherwise. */}
|
|
85
|
+
<div
|
|
86
|
+
className={cn(
|
|
87
|
+
"flex w-full flex-col items-start pt-6",
|
|
88
|
+
people.length > 0 &&
|
|
89
|
+
"min-h-[var(--lifeline-people-top)] pb-6",
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
{marker.badges && marker.badges.length > 0 && (
|
|
93
|
+
<div className="mb-3 flex items-center justify-start gap-2">
|
|
94
|
+
{marker.badges.map((badge) => (
|
|
95
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
96
|
+
<img
|
|
97
|
+
key={badge.src}
|
|
98
|
+
src={badge.src}
|
|
99
|
+
alt={badge.alt}
|
|
100
|
+
className="h-6 w-6 object-contain opacity-80 transition-opacity duration-300 group-hover:opacity-100"
|
|
101
|
+
/>
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
{marker.companies && marker.companies.length > 0 && (
|
|
107
|
+
<div className="mb-2 flex items-center justify-start gap-1.5">
|
|
108
|
+
{marker.companies.map((company) => (
|
|
109
|
+
<CompanyIcon
|
|
110
|
+
key={company.id}
|
|
111
|
+
id={company.id}
|
|
112
|
+
label={company.name}
|
|
113
|
+
className="opacity-70 transition-opacity duration-300 group-hover:opacity-100"
|
|
114
|
+
/>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
<div className="min-h-[3.25rem] space-y-4">
|
|
120
|
+
{marker.events.map((event, index) => {
|
|
121
|
+
const image = getLifelineEventImage(event)
|
|
122
|
+
const effect = getLifelineEventEffect(event)
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<p
|
|
126
|
+
key={getLifelineEventKey(event, index)}
|
|
127
|
+
className={cn(
|
|
128
|
+
"max-w-[18rem] text-left text-[14px] leading-[1.55] tracking-[-0.01em]",
|
|
129
|
+
effect && "cursor-pointer",
|
|
130
|
+
)}
|
|
131
|
+
data-lifeline-interactive={effect ? "" : undefined}
|
|
132
|
+
onMouseEnter={
|
|
133
|
+
image && hoverImage
|
|
134
|
+
? () => hoverImage.show(image)
|
|
135
|
+
: undefined
|
|
136
|
+
}
|
|
137
|
+
onMouseLeave={
|
|
138
|
+
image && hoverImage ? hoverImage.hide : undefined
|
|
139
|
+
}
|
|
140
|
+
onClick={
|
|
141
|
+
effect && fireworks
|
|
142
|
+
? () => fireworks.launch(effect)
|
|
143
|
+
: undefined
|
|
144
|
+
}
|
|
145
|
+
>
|
|
146
|
+
<LifelineEventText event={event} />
|
|
147
|
+
{image && (
|
|
148
|
+
// Glued to the last word with a no-break space so
|
|
149
|
+
// the icon can never wrap onto a line of its own.
|
|
150
|
+
<span className="whitespace-nowrap">
|
|
151
|
+
{" "}
|
|
152
|
+
{image.video ? (
|
|
153
|
+
<Film
|
|
154
|
+
className="ml-0.5 inline-block h-3 w-3 -translate-y-px text-fg-quaternary transition-colors duration-300"
|
|
155
|
+
strokeWidth={1.75}
|
|
156
|
+
aria-hidden="true"
|
|
157
|
+
/>
|
|
158
|
+
) : (
|
|
159
|
+
<ImageIcon
|
|
160
|
+
className="ml-0.5 inline-block h-3 w-3 -translate-y-px text-fg-quaternary transition-colors duration-300"
|
|
161
|
+
strokeWidth={1.75}
|
|
162
|
+
aria-hidden="true"
|
|
163
|
+
/>
|
|
164
|
+
)}
|
|
165
|
+
</span>
|
|
166
|
+
)}
|
|
167
|
+
</p>
|
|
168
|
+
)
|
|
169
|
+
})}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
{people.length > 0 && (
|
|
174
|
+
<div className="w-full">
|
|
175
|
+
<LifelinePeople people={people} />
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { LifelineMarker } from "./types"
|
|
2
|
+
|
|
3
|
+
export interface AggregatedLifelinePerson {
|
|
4
|
+
name: string
|
|
5
|
+
mentor: boolean
|
|
6
|
+
met: boolean
|
|
7
|
+
photo?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function aggregateLifelinePeople(
|
|
11
|
+
marker: LifelineMarker,
|
|
12
|
+
): AggregatedLifelinePerson[] {
|
|
13
|
+
const map = new Map<string, AggregatedLifelinePerson>()
|
|
14
|
+
|
|
15
|
+
const add = (
|
|
16
|
+
name: string,
|
|
17
|
+
type: "mentor" | "met",
|
|
18
|
+
photo?: string,
|
|
19
|
+
) => {
|
|
20
|
+
const person = map.get(name) ?? { name, mentor: false, met: false }
|
|
21
|
+
person[type] = true
|
|
22
|
+
person.photo = person.photo ?? photo
|
|
23
|
+
map.set(name, person)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
marker.mentors?.forEach((person) =>
|
|
27
|
+
add(person.name, "mentor", person.photo),
|
|
28
|
+
)
|
|
29
|
+
marker.met?.forEach((person) => add(person.name, "met", person.photo))
|
|
30
|
+
|
|
31
|
+
return [...map.values()]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getInitials(name: string) {
|
|
35
|
+
return name
|
|
36
|
+
.split(" ")
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.map((part) => part[0])
|
|
39
|
+
.join("")
|
|
40
|
+
.slice(0, 2)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface LifelinePeopleProps {
|
|
44
|
+
people: AggregatedLifelinePerson[]
|
|
45
|
+
allowWrap?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function LifelinePeople({
|
|
49
|
+
people,
|
|
50
|
+
allowWrap = false,
|
|
51
|
+
}: LifelinePeopleProps) {
|
|
52
|
+
if (people.length === 0) return null
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="w-full space-y-3">
|
|
56
|
+
{people.map((person) => (
|
|
57
|
+
<div key={person.name} className="flex w-full items-center gap-2.5">
|
|
58
|
+
<div className="flex w-3 shrink-0 items-center justify-center gap-0.5">
|
|
59
|
+
{person.mentor && (
|
|
60
|
+
<span
|
|
61
|
+
className="h-1.5 w-1.5 rounded-full bg-blue-500"
|
|
62
|
+
aria-hidden="true"
|
|
63
|
+
/>
|
|
64
|
+
)}
|
|
65
|
+
{person.met && (
|
|
66
|
+
<span
|
|
67
|
+
className="h-1.5 w-1.5 rounded-full bg-pink-500"
|
|
68
|
+
aria-hidden="true"
|
|
69
|
+
/>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
{person.photo ? (
|
|
73
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
74
|
+
<img
|
|
75
|
+
src={person.photo}
|
|
76
|
+
alt={person.name}
|
|
77
|
+
width={28}
|
|
78
|
+
height={28}
|
|
79
|
+
className="h-7 w-7 shrink-0 rounded-full object-cover"
|
|
80
|
+
/>
|
|
81
|
+
) : (
|
|
82
|
+
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-surface-inverted text-[10px] font-medium text-fg-inverted transition-colors duration-300">
|
|
83
|
+
{getInitials(person.name)}
|
|
84
|
+
</span>
|
|
85
|
+
)}
|
|
86
|
+
<p
|
|
87
|
+
className={
|
|
88
|
+
allowWrap
|
|
89
|
+
? "text-left text-[13px] leading-snug text-fg-tertiary transition-colors duration-300"
|
|
90
|
+
: "whitespace-nowrap text-left text-[13px] text-fg-tertiary transition-colors duration-300 group-hover:text-fg-secondary"
|
|
91
|
+
}
|
|
92
|
+
>
|
|
93
|
+
{person.name}
|
|
94
|
+
</p>
|
|
95
|
+
</div>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|