@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,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
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react"
|
|
12
|
+
import type { LifelineEventEffect } from "./types"
|
|
13
|
+
|
|
14
|
+
/** Tweak these */
|
|
15
|
+
const DURATION_S = 7.5
|
|
16
|
+
const MAX_DPR = 1.5
|
|
17
|
+
/** Wait for the theme cross-fade before the first burst. */
|
|
18
|
+
const NIGHTFALL_MS = 400
|
|
19
|
+
|
|
20
|
+
type Palette = [number[], number[], number[]]
|
|
21
|
+
|
|
22
|
+
const PALETTES: Record<LifelineEventEffect, Palette> = {
|
|
23
|
+
// Old Glory red / white / blue
|
|
24
|
+
fireworks: [
|
|
25
|
+
[0.9, 0.15, 0.25],
|
|
26
|
+
[1.0, 1.0, 1.0],
|
|
27
|
+
[0.25, 0.45, 0.95],
|
|
28
|
+
],
|
|
29
|
+
// celeste / white / pale celeste
|
|
30
|
+
"fireworks-argentina": [
|
|
31
|
+
[0.45, 0.75, 0.98],
|
|
32
|
+
[1.0, 1.0, 1.0],
|
|
33
|
+
[0.7, 0.87, 1.0],
|
|
34
|
+
],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const VERTEX_SHADER = `
|
|
38
|
+
attribute vec2 a_pos;
|
|
39
|
+
void main() {
|
|
40
|
+
gl_Position = vec4(a_pos, 0.0, 1.0);
|
|
41
|
+
}
|
|
42
|
+
`
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Additive point-glow fireworks in Old Glory red, white, and blue,
|
|
46
|
+
* composited over a night-sky scrim via premultiplied canvas alpha.
|
|
47
|
+
*/
|
|
48
|
+
const FRAGMENT_SHADER = `
|
|
49
|
+
precision highp float;
|
|
50
|
+
|
|
51
|
+
uniform vec2 u_res;
|
|
52
|
+
uniform float u_time;
|
|
53
|
+
uniform float u_dur;
|
|
54
|
+
uniform vec3 u_c0;
|
|
55
|
+
uniform vec3 u_c1;
|
|
56
|
+
uniform vec3 u_c2;
|
|
57
|
+
|
|
58
|
+
#define TAU 6.28318530718
|
|
59
|
+
#define N_FIREWORKS 10
|
|
60
|
+
#define N_PARTICLES 42
|
|
61
|
+
|
|
62
|
+
float hash(float n) {
|
|
63
|
+
return fract(sin(n) * 43758.5453123);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
vec3 palette(float m) {
|
|
67
|
+
if (m < 0.5) return u_c0;
|
|
68
|
+
if (m < 1.5) return u_c1;
|
|
69
|
+
return u_c2;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
void main() {
|
|
73
|
+
vec2 uv = (gl_FragCoord.xy - 0.5 * u_res) / u_res.y;
|
|
74
|
+
float t = u_time;
|
|
75
|
+
float env = smoothstep(0.0, 0.5, t) * (1.0 - smoothstep(u_dur - 1.0, u_dur, t));
|
|
76
|
+
|
|
77
|
+
vec3 col = vec3(0.0);
|
|
78
|
+
|
|
79
|
+
for (int i = 0; i < N_FIREWORKS; i++) {
|
|
80
|
+
float fi = float(i);
|
|
81
|
+
float t0 = 0.35 + fi * (u_dur - 2.8) / float(N_FIREWORKS) + hash(fi * 7.31) * 0.3;
|
|
82
|
+
// No flow control in the loop — some WebGL1 driver translations
|
|
83
|
+
// mishandle continue, so inactive bursts multiply to zero instead.
|
|
84
|
+
float active = step(t0, t) * step(t, t0 + 1.8);
|
|
85
|
+
float lt = clamp((t - t0) / 1.8, 0.0, 1.0);
|
|
86
|
+
|
|
87
|
+
vec2 center = vec2((hash(fi * 3.7) - 0.5) * 1.6, -0.05 + hash(fi * 9.1) * 0.5);
|
|
88
|
+
vec3 base = palette(mod(fi, 3.0)); // strict red / white / blue rotation
|
|
89
|
+
// Ramp in fast so overlapping particles at ignition don't stack
|
|
90
|
+
// into a blown-out ball, then decay.
|
|
91
|
+
float fade = exp(-lt * 4.0) * min(1.0, lt * 6.0);
|
|
92
|
+
|
|
93
|
+
for (int j = 0; j < N_PARTICLES; j++) {
|
|
94
|
+
float fj = float(j);
|
|
95
|
+
float angle = (fj / float(N_PARTICLES)) * TAU + hash(fi * 100.0 + fj) * 0.15;
|
|
96
|
+
float speed = 0.16 + 0.22 * hash(fj * 7.77 + fi * 31.3);
|
|
97
|
+
|
|
98
|
+
vec2 p = center + vec2(cos(angle), sin(angle)) * speed * sqrt(lt);
|
|
99
|
+
p.y -= 0.09 * lt * lt; // gravity
|
|
100
|
+
|
|
101
|
+
float d = max(length(uv - p), 0.004);
|
|
102
|
+
float sparkle = 0.7 + 0.3 * sin(30.0 * lt + fj * 1.7);
|
|
103
|
+
col += base * active * fade * sparkle * 0.0006 / (d * d);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
col = clamp(col * env, 0.0, 1.0);
|
|
108
|
+
float spark = max(col.r, max(col.g, col.b));
|
|
109
|
+
float scrim = 0.45 * env;
|
|
110
|
+
gl_FragColor = vec4(col, max(spark, scrim));
|
|
111
|
+
}
|
|
112
|
+
`
|
|
113
|
+
|
|
114
|
+
interface LifelineFireworksApi {
|
|
115
|
+
launch: (effect: LifelineEventEffect) => void
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const LifelineFireworksContext = createContext<LifelineFireworksApi | null>(
|
|
119
|
+
null,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
export function useLifelineFireworks() {
|
|
123
|
+
return useContext(LifelineFireworksContext)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function FireworksCanvas({
|
|
127
|
+
palette,
|
|
128
|
+
onDone,
|
|
129
|
+
}: {
|
|
130
|
+
palette: Palette
|
|
131
|
+
onDone: () => void
|
|
132
|
+
}) {
|
|
133
|
+
const paletteRef = useRef(palette)
|
|
134
|
+
paletteRef.current = palette
|
|
135
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
136
|
+
const onDoneRef = useRef(onDone)
|
|
137
|
+
onDoneRef.current = onDone
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const canvas = canvasRef.current
|
|
141
|
+
if (!canvas) return
|
|
142
|
+
|
|
143
|
+
const gl = canvas.getContext("webgl", {
|
|
144
|
+
alpha: true,
|
|
145
|
+
premultipliedAlpha: true,
|
|
146
|
+
antialias: false,
|
|
147
|
+
})
|
|
148
|
+
if (!gl) {
|
|
149
|
+
onDoneRef.current()
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const compile = (type: number, source: string) => {
|
|
154
|
+
const shader = gl.createShader(type)!
|
|
155
|
+
gl.shaderSource(shader, source)
|
|
156
|
+
gl.compileShader(shader)
|
|
157
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
158
|
+
console.warn("fireworks shader:", gl.getShaderInfoLog(shader))
|
|
159
|
+
}
|
|
160
|
+
return shader
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const program = gl.createProgram()!
|
|
164
|
+
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX_SHADER))
|
|
165
|
+
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAGMENT_SHADER))
|
|
166
|
+
gl.linkProgram(program)
|
|
167
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
168
|
+
console.warn("fireworks link:", gl.getProgramInfoLog(program))
|
|
169
|
+
onDoneRef.current()
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
gl.useProgram(program)
|
|
173
|
+
|
|
174
|
+
const buffer = gl.createBuffer()
|
|
175
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
|
|
176
|
+
gl.bufferData(
|
|
177
|
+
gl.ARRAY_BUFFER,
|
|
178
|
+
new Float32Array([-1, -1, 3, -1, -1, 3]),
|
|
179
|
+
gl.STATIC_DRAW,
|
|
180
|
+
)
|
|
181
|
+
const aPos = gl.getAttribLocation(program, "a_pos")
|
|
182
|
+
gl.enableVertexAttribArray(aPos)
|
|
183
|
+
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0)
|
|
184
|
+
|
|
185
|
+
const uRes = gl.getUniformLocation(program, "u_res")
|
|
186
|
+
const uTime = gl.getUniformLocation(program, "u_time")
|
|
187
|
+
const uDur = gl.getUniformLocation(program, "u_dur")
|
|
188
|
+
|
|
189
|
+
const [c0, c1, c2] = paletteRef.current
|
|
190
|
+
gl.uniform3f(gl.getUniformLocation(program, "u_c0"), c0[0], c0[1], c0[2])
|
|
191
|
+
gl.uniform3f(gl.getUniformLocation(program, "u_c1"), c1[0], c1[1], c1[2])
|
|
192
|
+
gl.uniform3f(gl.getUniformLocation(program, "u_c2"), c2[0], c2[1], c2[2])
|
|
193
|
+
|
|
194
|
+
const resize = () => {
|
|
195
|
+
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR)
|
|
196
|
+
canvas.width = Math.round(window.innerWidth * dpr)
|
|
197
|
+
canvas.height = Math.round(window.innerHeight * dpr)
|
|
198
|
+
gl.viewport(0, 0, canvas.width, canvas.height)
|
|
199
|
+
}
|
|
200
|
+
resize()
|
|
201
|
+
window.addEventListener("resize", resize)
|
|
202
|
+
|
|
203
|
+
let frame = 0
|
|
204
|
+
const start = performance.now()
|
|
205
|
+
|
|
206
|
+
const step = (now: number) => {
|
|
207
|
+
const t = (now - start) / 1000
|
|
208
|
+
if (t >= DURATION_S) {
|
|
209
|
+
onDoneRef.current()
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
gl.uniform2f(uRes, canvas.width, canvas.height)
|
|
214
|
+
gl.uniform1f(uTime, t)
|
|
215
|
+
gl.uniform1f(uDur, DURATION_S)
|
|
216
|
+
gl.clearColor(0, 0, 0, 0)
|
|
217
|
+
gl.clear(gl.COLOR_BUFFER_BIT)
|
|
218
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3)
|
|
219
|
+
|
|
220
|
+
frame = requestAnimationFrame(step)
|
|
221
|
+
}
|
|
222
|
+
frame = requestAnimationFrame(step)
|
|
223
|
+
|
|
224
|
+
return () => {
|
|
225
|
+
cancelAnimationFrame(frame)
|
|
226
|
+
window.removeEventListener("resize", resize)
|
|
227
|
+
// No manual loseContext: StrictMode re-runs this effect on the
|
|
228
|
+
// same canvas, and a lost context can never compile again.
|
|
229
|
+
}
|
|
230
|
+
}, [])
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<canvas
|
|
234
|
+
ref={canvasRef}
|
|
235
|
+
aria-hidden="true"
|
|
236
|
+
className="pointer-events-none fixed inset-0 z-[70] h-full w-full"
|
|
237
|
+
/>
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function isDarkDocument() {
|
|
242
|
+
return document.documentElement.classList.contains("dark")
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function setDocumentDark(dark: boolean) {
|
|
246
|
+
document.documentElement.classList.toggle("dark", dark)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function LifelineFireworksProvider({
|
|
250
|
+
children,
|
|
251
|
+
}: {
|
|
252
|
+
children: ReactNode
|
|
253
|
+
}) {
|
|
254
|
+
const [playing, setPlaying] = useState(false)
|
|
255
|
+
const [effect, setEffect] = useState<LifelineEventEffect>("fireworks")
|
|
256
|
+
const restoreThemeRef = useRef<"light" | null>(null)
|
|
257
|
+
const nightfallRef = useRef(0)
|
|
258
|
+
const playingRef = useRef(false)
|
|
259
|
+
playingRef.current = playing
|
|
260
|
+
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
return () => window.clearTimeout(nightfallRef.current)
|
|
263
|
+
}, [])
|
|
264
|
+
|
|
265
|
+
const launch = useCallback((nextEffect: LifelineEventEffect) => {
|
|
266
|
+
if (playingRef.current) return
|
|
267
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
|
|
268
|
+
|
|
269
|
+
setEffect(nextEffect)
|
|
270
|
+
|
|
271
|
+
// Fireworks belong in the dark: switch a light page to dark for
|
|
272
|
+
// the show, and restore afterwards.
|
|
273
|
+
if (!isDarkDocument()) {
|
|
274
|
+
restoreThemeRef.current = "light"
|
|
275
|
+
setDocumentDark(true)
|
|
276
|
+
window.clearTimeout(nightfallRef.current)
|
|
277
|
+
nightfallRef.current = window.setTimeout(
|
|
278
|
+
() => setPlaying(true),
|
|
279
|
+
NIGHTFALL_MS,
|
|
280
|
+
)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
restoreThemeRef.current = null
|
|
285
|
+
setPlaying(true)
|
|
286
|
+
}, [])
|
|
287
|
+
|
|
288
|
+
const done = useCallback(() => {
|
|
289
|
+
setPlaying(false)
|
|
290
|
+
if (restoreThemeRef.current) {
|
|
291
|
+
setDocumentDark(false)
|
|
292
|
+
restoreThemeRef.current = null
|
|
293
|
+
}
|
|
294
|
+
}, [])
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<LifelineFireworksContext.Provider value={{ launch }}>
|
|
298
|
+
{children}
|
|
299
|
+
{playing && <FireworksCanvas palette={PALETTES[effect]} onDone={done} />}
|
|
300
|
+
</LifelineFireworksContext.Provider>
|
|
301
|
+
)
|
|
302
|
+
}
|