@bearmenu/ui 0.8.4 → 0.8.7
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/dist/chunk-637L7XQ3.js +85 -0
- package/dist/chunk-6XMYEZVR.js +26 -0
- package/dist/chunk-DNSXMHYX.js +8 -0
- package/dist/chunk-FDITZ2VM.js +16 -0
- package/dist/chunk-GPH6L4DX.js +55 -0
- package/dist/{chunk-N2KNBPAQ.js → chunk-QXHMPJ35.js} +1 -0
- package/dist/chunk-TT3WFS6S.js +379 -0
- package/dist/chunk-UXWAOISG.js +170 -0
- package/dist/{chunk-UZ3PCIE5.js → chunk-XWMRABZW.js} +2 -2
- package/dist/{chunk-VBA45GLP.js → chunk-ZDAHKI3A.js} +12 -2
- package/dist/components/card.d.ts +2 -2
- package/dist/components/card.js +4 -127
- package/dist/components/category-deck.d.ts +50 -0
- package/dist/components/category-deck.js +128 -0
- package/dist/components/concept-card.d.ts +43 -0
- package/dist/components/concept-card.js +81 -0
- package/dist/components/coverflow.d.ts +123 -0
- package/dist/components/coverflow.js +300 -0
- package/dist/components/day-rail-chip.d.ts +82 -0
- package/dist/components/day-rail-chip.js +105 -0
- package/dist/components/drift-rail.d.ts +28 -0
- package/dist/components/drift-rail.js +3 -0
- package/dist/components/event-card.d.ts +97 -0
- package/dist/components/event-card.js +164 -0
- package/dist/components/event-compact-row.d.ts +73 -0
- package/dist/components/event-compact-row.js +134 -0
- package/dist/components/event-day-header.d.ts +65 -0
- package/dist/components/event-day-header.js +95 -0
- package/dist/components/event-day-separator.d.ts +46 -0
- package/dist/components/event-day-separator.js +50 -0
- package/dist/components/event-detail-panel.d.ts +80 -0
- package/dist/components/event-detail-panel.js +251 -0
- package/dist/components/event-hub-hero.d.ts +34 -0
- package/dist/components/event-hub-hero.js +57 -0
- package/dist/components/event-index-row.d.ts +68 -0
- package/dist/components/event-index-row.js +84 -0
- package/dist/components/event-list-row-wide.d.ts +101 -0
- package/dist/components/event-list-row-wide.js +218 -0
- package/dist/components/event-list-row.d.ts +82 -0
- package/dist/components/event-list-row.js +173 -0
- package/dist/components/event-poster-fallback.d.ts +20 -0
- package/dist/components/event-poster-fallback.js +3 -0
- package/dist/components/event-types.d.ts +122 -0
- package/dist/components/event-types.js +2 -0
- package/dist/components/events-empty-state.d.ts +44 -0
- package/dist/components/events-empty-state.js +73 -0
- package/dist/components/facet-group.d.ts +93 -0
- package/dist/components/facet-group.js +222 -0
- package/dist/components/featured-rail-item.d.ts +42 -0
- package/dist/components/featured-rail-item.js +88 -0
- package/dist/components/hour-band.d.ts +68 -0
- package/dist/components/hour-band.js +53 -0
- package/dist/components/hour-group.d.ts +67 -0
- package/dist/components/hour-group.js +59 -0
- package/dist/components/motion-icon-controls.js +3 -379
- package/dist/components/multi-select-combobox.js +1 -1
- package/dist/components/place-about-tab.js +3 -3
- package/dist/components/place-details-mobile.js +3 -3
- package/dist/components/poster-stack.d.ts +30 -0
- package/dist/components/poster-stack.js +104 -0
- package/dist/components/poster-tape.d.ts +36 -0
- package/dist/components/poster-tape.js +64 -0
- package/dist/components/poster-wall.d.ts +25 -0
- package/dist/components/poster-wall.js +183 -0
- package/dist/components/quiet-stretch.d.ts +43 -0
- package/dist/components/quiet-stretch.js +54 -0
- package/dist/components/section-header.d.ts +53 -0
- package/dist/components/section-header.js +63 -0
- package/dist/components/segmented-nav.d.ts +62 -0
- package/dist/components/segmented-nav.js +54 -0
- package/dist/components/skeleton-card.js +1 -1
- package/dist/components/skeleton.d.ts +17 -0
- package/dist/components/skeleton.js +1 -1
- package/dist/components/tag-group.js +3 -26
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/tag.js +1 -1
- package/dist/components/timeline-band.d.ts +46 -0
- package/dist/components/timeline-band.js +5 -0
- package/dist/components/venue-events-block.d.ts +143 -0
- package/dist/components/venue-events-block.js +222 -0
- package/dist/components/weekend-panel.d.ts +44 -0
- package/dist/components/weekend-panel.js +231 -0
- package/package.json +1 -1
- package/src/components/card.tsx +44 -1
- package/src/components/category-deck.stories.tsx +96 -0
- package/src/components/category-deck.tsx +227 -0
- package/src/components/concept-card.stories.tsx +80 -0
- package/src/components/concept-card.tsx +143 -0
- package/src/components/coverflow.stories.tsx +240 -0
- package/src/components/coverflow.test.tsx +193 -0
- package/src/components/coverflow.tsx +550 -0
- package/src/components/day-rail-chip.stories.tsx +145 -0
- package/src/components/day-rail-chip.tsx +201 -0
- package/src/components/drift-rail.stories.tsx +92 -0
- package/src/components/drift-rail.tsx +86 -0
- package/src/components/event-card.stories.tsx +248 -0
- package/src/components/event-card.test.tsx +188 -0
- package/src/components/event-card.tsx +329 -0
- package/src/components/event-compact-row.stories.tsx +221 -0
- package/src/components/event-compact-row.test.tsx +51 -0
- package/src/components/event-compact-row.tsx +270 -0
- package/src/components/event-day-header.stories.tsx +124 -0
- package/src/components/event-day-header.tsx +180 -0
- package/src/components/event-day-separator.stories.tsx +70 -0
- package/src/components/event-day-separator.tsx +95 -0
- package/src/components/event-detail-panel.stories.tsx +191 -0
- package/src/components/event-detail-panel.tsx +360 -0
- package/src/components/event-hub-hero.stories.tsx +66 -0
- package/src/components/event-hub-hero.tsx +110 -0
- package/src/components/event-index-row.stories.tsx +116 -0
- package/src/components/event-index-row.tsx +181 -0
- package/src/components/event-list-row-wide.stories.tsx +204 -0
- package/src/components/event-list-row-wide.tsx +386 -0
- package/src/components/event-list-row.stories.tsx +159 -0
- package/src/components/event-list-row.tsx +335 -0
- package/src/components/event-poster-fallback.stories.tsx +52 -0
- package/src/components/event-poster-fallback.tsx +31 -0
- package/src/components/event-types.ts +137 -0
- package/src/components/events-empty-state.stories.tsx +59 -0
- package/src/components/events-empty-state.tsx +131 -0
- package/src/components/facet-group.stories.tsx +247 -0
- package/src/components/facet-group.test.tsx +225 -0
- package/src/components/facet-group.tsx +352 -0
- package/src/components/featured-rail-item.stories.tsx +86 -0
- package/src/components/featured-rail-item.tsx +153 -0
- package/src/components/hour-band.stories.tsx +159 -0
- package/src/components/hour-band.tsx +126 -0
- package/src/components/hour-group.stories.tsx +100 -0
- package/src/components/hour-group.tsx +124 -0
- package/src/components/poster-stack.stories.tsx +68 -0
- package/src/components/poster-stack.tsx +155 -0
- package/src/components/poster-tape.stories.tsx +70 -0
- package/src/components/poster-tape.tsx +122 -0
- package/src/components/poster-wall.stories.tsx +95 -0
- package/src/components/poster-wall.tsx +220 -0
- package/src/components/quiet-stretch.stories.tsx +62 -0
- package/src/components/quiet-stretch.tsx +115 -0
- package/src/components/section-header.stories.tsx +70 -0
- package/src/components/section-header.tsx +134 -0
- package/src/components/segmented-nav.stories.tsx +118 -0
- package/src/components/segmented-nav.tsx +113 -0
- package/src/components/skeleton.tsx +18 -0
- package/src/components/tag.tsx +11 -1
- package/src/components/timeline-band.stories.tsx +206 -0
- package/src/components/timeline-band.tsx +207 -0
- package/src/components/venue-events-block.stories.tsx +216 -0
- package/src/components/venue-events-block.tsx +408 -0
- package/src/components/weekend-panel.stories.tsx +92 -0
- package/src/components/weekend-panel.tsx +322 -0
- package/src/globals.css +19 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PosterStack — three overlapping, gently rotated posters that float, the
|
|
9
|
+
* hub hero's aside slot in the source design.
|
|
10
|
+
*
|
|
11
|
+
* Two motion rules, both mandatory:
|
|
12
|
+
* 1. Stops entirely under `prefers-reduced-motion: reduce` — enforced via
|
|
13
|
+
* an injected `<style>` media query rather than relying on Tailwind's
|
|
14
|
+
* `motion-reduce:` variant, because the float is a custom `@keyframes`
|
|
15
|
+
* animation Tailwind cannot express, and only a `!important` CSS rule
|
|
16
|
+
* reliably beats the element's own inline `animation` style.
|
|
17
|
+
* 2. Pauses while the stack is scrolled off-screen — an IntersectionObserver
|
|
18
|
+
* toggles `animation-play-state` on the three poster nodes so nothing
|
|
19
|
+
* spends a paint budget while it isn't visible. Skipped entirely when
|
|
20
|
+
* reduced motion is on, since nothing is animating to pause.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type PosterStackPoster = {
|
|
24
|
+
/** Absent renders a neutral placeholder block instead of an image. */
|
|
25
|
+
src?: string;
|
|
26
|
+
alt: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type PosterStackProps = {
|
|
30
|
+
/** Rendered in order; only the first three are shown. */
|
|
31
|
+
posters: PosterStackPoster[];
|
|
32
|
+
className?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const POSTER_STACK_OBSERVER_ROOT_MARGIN = "200px";
|
|
36
|
+
|
|
37
|
+
const POSTER_STACK_ANIMATION_STYLE = `
|
|
38
|
+
@keyframes ds-poster-stack-float-1 { to { transform: rotate(-5deg) translateY(-16px); } }
|
|
39
|
+
@keyframes ds-poster-stack-float-2 { to { transform: rotate(3deg) translateY(14px); } }
|
|
40
|
+
@keyframes ds-poster-stack-float-3 { to { transform: rotate(6deg) translateY(-11px); } }
|
|
41
|
+
@media (prefers-reduced-motion: reduce) {
|
|
42
|
+
[data-ds-poster-stack-item] { animation: none !important; }
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
type PosterSlotConfig = {
|
|
47
|
+
positionClassName: string;
|
|
48
|
+
restTransform: string;
|
|
49
|
+
animationName: string;
|
|
50
|
+
animationDurationMs: number;
|
|
51
|
+
zIndexClassName?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const POSTER_SLOTS: PosterSlotConfig[] = [
|
|
55
|
+
{
|
|
56
|
+
positionClassName: "left-[6px] top-[34px] w-[120px] md:left-0 md:top-[52px] md:w-[196px]",
|
|
57
|
+
restTransform: "rotate(-5deg)",
|
|
58
|
+
animationName: "ds-poster-stack-float-1",
|
|
59
|
+
animationDurationMs: 9000,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
positionClassName: "left-[110px] top-0 w-[140px] md:left-[168px] md:w-[232px]",
|
|
63
|
+
restTransform: "rotate(3deg)",
|
|
64
|
+
animationName: "ds-poster-stack-float-2",
|
|
65
|
+
animationDurationMs: 11000,
|
|
66
|
+
zIndexClassName: "z-[3]",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
positionClassName: "left-[238px] top-[58px] w-[120px] md:left-[378px] md:top-24 md:w-[196px]",
|
|
70
|
+
restTransform: "rotate(6deg)",
|
|
71
|
+
animationName: "ds-poster-stack-float-3",
|
|
72
|
+
animationDurationMs: 10000,
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/** A warm, drop-shadowed decorative float — not a content surface, so the
|
|
77
|
+
* shadow is an art-directed literal rather than a `--shadow-card` token. */
|
|
78
|
+
const POSTER_SHADOW = "0 22px 50px oklch(0.3 0.02 60 / 0.24)";
|
|
79
|
+
|
|
80
|
+
export function PosterStack({ posters, className }: PosterStackProps) {
|
|
81
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
82
|
+
const posterRefs = useRef<Array<HTMLDivElement | null>>([]);
|
|
83
|
+
const [failedIndices, setFailedIndices] = useState<Set<number>>(new Set());
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (typeof window === "undefined") return;
|
|
87
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
88
|
+
|
|
89
|
+
const container = containerRef.current;
|
|
90
|
+
if (!container) return;
|
|
91
|
+
|
|
92
|
+
const observer = new IntersectionObserver(
|
|
93
|
+
([entry]) => {
|
|
94
|
+
const playState = entry.isIntersecting ? "running" : "paused";
|
|
95
|
+
for (const poster of posterRefs.current) {
|
|
96
|
+
if (poster) poster.style.animationPlayState = playState;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
{ rootMargin: POSTER_STACK_OBSERVER_ROOT_MARGIN }
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
observer.observe(container);
|
|
103
|
+
return () => observer.disconnect();
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
const visiblePosters = posters.slice(0, POSTER_SLOTS.length);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div ref={containerRef} className={cn("relative h-[250px] md:h-[400px]", className)}>
|
|
110
|
+
<style>{POSTER_STACK_ANIMATION_STYLE}</style>
|
|
111
|
+
|
|
112
|
+
{visiblePosters.map((poster, index) => {
|
|
113
|
+
const slot = POSTER_SLOTS[index];
|
|
114
|
+
const showPoster = !!poster.src && !failedIndices.has(index);
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
key={poster.alt || index}
|
|
118
|
+
ref={(node) => {
|
|
119
|
+
posterRefs.current[index] = node;
|
|
120
|
+
}}
|
|
121
|
+
data-ds-poster-stack-item
|
|
122
|
+
className={cn(
|
|
123
|
+
"absolute aspect-[3/4] overflow-hidden rounded-xl bg-muted",
|
|
124
|
+
slot.positionClassName,
|
|
125
|
+
slot.zIndexClassName
|
|
126
|
+
)}
|
|
127
|
+
style={{
|
|
128
|
+
transform: slot.restTransform,
|
|
129
|
+
boxShadow: POSTER_SHADOW,
|
|
130
|
+
animationName: slot.animationName,
|
|
131
|
+
animationDuration: `${slot.animationDurationMs}ms`,
|
|
132
|
+
animationTimingFunction: "ease-in-out",
|
|
133
|
+
animationIterationCount: "infinite",
|
|
134
|
+
animationDirection: "alternate",
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
{showPoster && (
|
|
138
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
139
|
+
<img
|
|
140
|
+
src={poster.src}
|
|
141
|
+
alt={poster.alt}
|
|
142
|
+
loading="lazy"
|
|
143
|
+
decoding="async"
|
|
144
|
+
className="h-full w-full object-cover"
|
|
145
|
+
onError={() =>
|
|
146
|
+
setFailedIndices((current) => new Set(current).add(index))
|
|
147
|
+
}
|
|
148
|
+
/>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
})}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { PosterTape, type PosterTapeItem } from "./poster-tape";
|
|
3
|
+
|
|
4
|
+
const items: PosterTapeItem[] = [
|
|
5
|
+
{ id: "1", title: "Warehouse Rave", meta: "SAT 20:00" },
|
|
6
|
+
{ id: "2", title: "Jazz on the Rooftop", meta: "FRI 19:30" },
|
|
7
|
+
{ id: "3", title: "Open Mic Night", meta: "THU 21:00" },
|
|
8
|
+
{ id: "4", title: "Street Food Market", meta: "SUN 12:00" },
|
|
9
|
+
{ id: "5", title: "Vinyl & Vermouth", meta: "WED 18:00" },
|
|
10
|
+
{ id: "6", title: "Psihotrop — From the Beginning to Infinity", meta: "SAT 22:00" },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
// Explicitly annotated rather than `satisfies`: the meta-level decorator makes
|
|
14
|
+
// the inferred type reference storybook's internal CSF module, which tsc cannot
|
|
15
|
+
// name portably (TS2742).
|
|
16
|
+
const meta: Meta<typeof PosterTape> = {
|
|
17
|
+
title: "Events/PosterTape",
|
|
18
|
+
component: PosterTape,
|
|
19
|
+
tags: ["autodocs"],
|
|
20
|
+
args: { items },
|
|
21
|
+
decorators: [
|
|
22
|
+
(Story) => (
|
|
23
|
+
<div className="max-w-[420px]">
|
|
24
|
+
<Story />
|
|
25
|
+
</div>
|
|
26
|
+
),
|
|
27
|
+
],
|
|
28
|
+
parameters: {
|
|
29
|
+
docs: {
|
|
30
|
+
description: {
|
|
31
|
+
component:
|
|
32
|
+
"The closing tape: small 4:5 posters drifting past at a slower, quieter speed than the featured rail. Built on `<DriftRail>` — same seamless loop and pause mechanics, just smaller items and a 60s default.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default meta;
|
|
39
|
+
type Story = StoryObj<typeof PosterTape>;
|
|
40
|
+
|
|
41
|
+
/** No poster URLs supplied — every tile falls back to `<EventPosterFallback>`, same as `<EventCard>`. */
|
|
42
|
+
export const Default: Story = {};
|
|
43
|
+
|
|
44
|
+
export const Linked: Story = {
|
|
45
|
+
args: {
|
|
46
|
+
items: items.map((item) => ({ ...item, href: `/events/${item.id}` })),
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const Faster: Story = {
|
|
51
|
+
args: { durationSeconds: 24 },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Titles wrap in full at the tape's 132px width. This is the surface the
|
|
56
|
+
* report came from — "Movie Night: Futni m…", "Juventus vs AC Milan…" — and
|
|
57
|
+
* `<DriftRail>` lays its items out `items-stretch`, so a wrapped title makes
|
|
58
|
+
* the tape taller without breaking the seamless loop or the poster row's
|
|
59
|
+
* alignment.
|
|
60
|
+
*/
|
|
61
|
+
export const LongTitles: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
items: [
|
|
64
|
+
{ id: "1", title: "Movie Night: Futni må ikke være så vanskelig", meta: "SAT 20:00" },
|
|
65
|
+
{ id: "2", title: "Juventus vs AC Milan — Serie A, live on the big screen", meta: "SUN 21:45" },
|
|
66
|
+
{ id: "3", title: "Afterwork Karaoke at the Old Brewery Courtyard", meta: "THU 18:30" },
|
|
67
|
+
{ id: "4", title: "Open Mic", meta: "WED 21:00" },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { DriftRail } from "./drift-rail";
|
|
7
|
+
import { EventPosterFallback } from "./event-poster-fallback";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* PosterTape — the closing marquee: small 4:5 posters drifting past at a
|
|
11
|
+
* slower, quieter speed than `<DriftRail>`'s default. Built on top of
|
|
12
|
+
* `<DriftRail>` rather than re-implementing the loop: both share the exact
|
|
13
|
+
* same seam-free-duplication and pause mechanics (`IntersectionObserver` +
|
|
14
|
+
* `prefers-reduced-motion`), and `<PosterTape>` only owns the item markup and
|
|
15
|
+
* a slower default speed.
|
|
16
|
+
*/
|
|
17
|
+
export type PosterTapeItem = {
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
/** Caller-composed, e.g. "Sat 5 · 20:00". Rendered as mono, small. */
|
|
21
|
+
meta: string;
|
|
22
|
+
posterUrl?: string;
|
|
23
|
+
href?: string;
|
|
24
|
+
/** Rendered instead of the poster when it's absent or failed to load. Defaults to `<EventPosterFallback />`. */
|
|
25
|
+
fallback?: ReactNode;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type PosterTapeLinkProps = {
|
|
29
|
+
href: string;
|
|
30
|
+
className?: string;
|
|
31
|
+
children: React.ReactNode;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function DefaultLink({ href, ...props }: PosterTapeLinkProps) {
|
|
35
|
+
return <a href={href} {...props} />;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type PosterTapeProps = {
|
|
39
|
+
items: PosterTapeItem[];
|
|
40
|
+
/** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
|
|
41
|
+
linkComponent?: React.ComponentType<PosterTapeLinkProps>;
|
|
42
|
+
durationSeconds?: number;
|
|
43
|
+
className?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const POSTER_TAPE_DEFAULT_DURATION_SECONDS = 60;
|
|
47
|
+
|
|
48
|
+
function PosterTapeItemCard({
|
|
49
|
+
item,
|
|
50
|
+
LinkComponent,
|
|
51
|
+
}: {
|
|
52
|
+
item: PosterTapeItem;
|
|
53
|
+
LinkComponent: React.ComponentType<PosterTapeLinkProps>;
|
|
54
|
+
}) {
|
|
55
|
+
const [imgError, setImgError] = useState(false);
|
|
56
|
+
const showPoster = !!item.posterUrl && !imgError;
|
|
57
|
+
|
|
58
|
+
const body = (
|
|
59
|
+
<>
|
|
60
|
+
<div className="aspect-[4/5] w-full overflow-hidden rounded-xl bg-muted">
|
|
61
|
+
{showPoster ? (
|
|
62
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
63
|
+
<img
|
|
64
|
+
src={item.posterUrl}
|
|
65
|
+
alt={item.title}
|
|
66
|
+
title={item.title}
|
|
67
|
+
loading="lazy"
|
|
68
|
+
decoding="async"
|
|
69
|
+
className="h-full w-full object-cover"
|
|
70
|
+
onError={() => setImgError(true)}
|
|
71
|
+
/>
|
|
72
|
+
) : (
|
|
73
|
+
(item.fallback ?? <EventPosterFallback />)
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
{/* The tape is 132px wide, which is exactly why the title used to read
|
|
77
|
+
"Movie Night: Futni m…". It wraps in full instead. `<DriftRail>` lays
|
|
78
|
+
its items out `items-stretch`, so a taller card only makes the tape
|
|
79
|
+
taller — the posters stay on one line and the loop's duplicate pass
|
|
80
|
+
is still pixel-identical, because both passes render the same items. */}
|
|
81
|
+
<div className="mt-1.5 text-[11px] font-semibold leading-[1.3] sm:mt-[7px] sm:text-[11.5px]">
|
|
82
|
+
{item.title}
|
|
83
|
+
</div>
|
|
84
|
+
<div className="mt-0.5 truncate font-mono text-[10px] text-muted-foreground sm:text-[10.5px]">
|
|
85
|
+
{item.meta}
|
|
86
|
+
</div>
|
|
87
|
+
</>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const itemClasses = "block w-[132px] shrink-0";
|
|
91
|
+
|
|
92
|
+
if (item.href) {
|
|
93
|
+
return (
|
|
94
|
+
<LinkComponent
|
|
95
|
+
href={item.href}
|
|
96
|
+
className={cn(
|
|
97
|
+
itemClasses,
|
|
98
|
+
"rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
{body}
|
|
102
|
+
</LinkComponent>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return <div className={itemClasses}>{body}</div>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function PosterTape({
|
|
110
|
+
items,
|
|
111
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
112
|
+
durationSeconds = POSTER_TAPE_DEFAULT_DURATION_SECONDS,
|
|
113
|
+
className,
|
|
114
|
+
}: PosterTapeProps) {
|
|
115
|
+
return (
|
|
116
|
+
<DriftRail durationSeconds={durationSeconds} className={className}>
|
|
117
|
+
{items.map((item) => (
|
|
118
|
+
<PosterTapeItemCard key={item.id} item={item} LinkComponent={LinkComponent} />
|
|
119
|
+
))}
|
|
120
|
+
</DriftRail>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { PosterWall, PosterWallSkeleton } from "./poster-wall";
|
|
3
|
+
|
|
4
|
+
function tile(color: string) {
|
|
5
|
+
return {
|
|
6
|
+
src:
|
|
7
|
+
"data:image/svg+xml;utf8," +
|
|
8
|
+
encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${color}"/></svg>`),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const TILE_COLORS = ["#7c5a3a", "#5a4a3a", "#8a6a4a", "#3a2a1a", "#6a4a2a", "#4a3a2a"];
|
|
13
|
+
|
|
14
|
+
function tiles(count: number) {
|
|
15
|
+
return Array.from({ length: count }, (_, index) => tile(TILE_COLORS[index % TILE_COLORS.length]));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Explicitly annotated rather than `satisfies`: the meta-level decorator makes
|
|
19
|
+
// the inferred type reference storybook's internal CSF module, which tsc cannot
|
|
20
|
+
// name portably (TS2742).
|
|
21
|
+
const meta: Meta<typeof PosterWall> = {
|
|
22
|
+
title: "Events/PosterWall",
|
|
23
|
+
component: PosterWall,
|
|
24
|
+
tags: ["autodocs"],
|
|
25
|
+
parameters: {
|
|
26
|
+
layout: "fullscreen",
|
|
27
|
+
docs: {
|
|
28
|
+
description: {
|
|
29
|
+
component:
|
|
30
|
+
"The hub's masthead — a dark tiled photo grid behind a sweeping light and a veil. `children` is a slot; the wall only owns the chrome and the container sizing/alignment for `variant`.",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default meta;
|
|
37
|
+
type Story = StoryObj<typeof PosterWall>;
|
|
38
|
+
|
|
39
|
+
export const Default: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
tiles: tiles(48),
|
|
42
|
+
children: (
|
|
43
|
+
<>
|
|
44
|
+
<div className="font-mono text-xs uppercase tracking-[0.2em] text-white/70">
|
|
45
|
+
Events this weekend
|
|
46
|
+
</div>
|
|
47
|
+
<h1 className="max-w-md text-4xl font-black tracking-[-0.02em]">
|
|
48
|
+
Where the city goes tonight
|
|
49
|
+
</h1>
|
|
50
|
+
</>
|
|
51
|
+
),
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The bottom-aligned mobile treatment: shorter wall, top-fading veil, fewer/larger tiles. */
|
|
56
|
+
export const Compact: Story = {
|
|
57
|
+
args: {
|
|
58
|
+
variant: "compact",
|
|
59
|
+
tiles: tiles(36),
|
|
60
|
+
children: (
|
|
61
|
+
<>
|
|
62
|
+
<div className="font-mono text-[11px] uppercase tracking-[0.2em] text-white/70">
|
|
63
|
+
This weekend
|
|
64
|
+
</div>
|
|
65
|
+
<h1 className="text-2xl font-black tracking-[-0.02em]">Where the city goes tonight</h1>
|
|
66
|
+
</>
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
decorators: [
|
|
70
|
+
(Story) => (
|
|
71
|
+
<div className="mx-auto w-[375px]">
|
|
72
|
+
<Story />
|
|
73
|
+
</div>
|
|
74
|
+
),
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** No tiles supplied — every slot falls back to the striped placeholder. */
|
|
79
|
+
export const NoTiles: Story = {
|
|
80
|
+
args: { children: <h1 className="text-3xl font-black">No poster data yet</h1> },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The tile grid at rest — no sweeping light in the loading state. */
|
|
84
|
+
export const Skeleton: Story = {
|
|
85
|
+
render: () => <PosterWallSkeleton />,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** The compact mobile masthead's loading state. */
|
|
89
|
+
export const SkeletonCompact: Story = {
|
|
90
|
+
render: () => (
|
|
91
|
+
<div className="mx-auto w-[375px]">
|
|
92
|
+
<PosterWallSkeleton variant="compact" />
|
|
93
|
+
</div>
|
|
94
|
+
),
|
|
95
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { Skeleton } from "./skeleton";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PosterWall — the hub's full-bleed masthead: a dark tiled photo grid behind a
|
|
9
|
+
* sweeping light and a veil, with the caller's content laid over it.
|
|
10
|
+
*
|
|
11
|
+
* The wall is deliberately dark and un-themed (fixed dark tokens, not
|
|
12
|
+
* `background`/`foreground`) in both light and dark mode — it's a photo
|
|
13
|
+
* backdrop, the same reasoning `EventCard`'s black overlay pills use over a
|
|
14
|
+
* poster image, not a themed surface.
|
|
15
|
+
*
|
|
16
|
+
* `children` is a slot: PosterWall only owns the tiles/light/veil chrome and
|
|
17
|
+
* the container's size, alignment and padding for the given `variant`. All
|
|
18
|
+
* overlay typography belongs to the caller.
|
|
19
|
+
*
|
|
20
|
+
* The sweep is a genuine `@keyframes` animation (not Framer Motion) because
|
|
21
|
+
* its exact timing is part of the design spec and it needs to keep running
|
|
22
|
+
* indefinitely at zero React cost — the keyframes are scoped to this file via
|
|
23
|
+
* a `<style>` tag since editing the DS's global stylesheet is out of scope
|
|
24
|
+
* for this component.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const POSTER_WALL_DESKTOP_TILE_COUNT = 48;
|
|
28
|
+
const POSTER_WALL_COMPACT_TILE_COUNT = 36;
|
|
29
|
+
const POSTER_WALL_SWEEP_KEYFRAMES_ID = "ds-poster-wall-sweep-keyframes";
|
|
30
|
+
|
|
31
|
+
function usePrefersReducedMotion(): boolean {
|
|
32
|
+
const [reduced, setReduced] = React.useState(false);
|
|
33
|
+
|
|
34
|
+
React.useEffect(() => {
|
|
35
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
36
|
+
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
37
|
+
setReduced(query.matches);
|
|
38
|
+
const onChange = () => setReduced(query.matches);
|
|
39
|
+
query.addEventListener("change", onChange);
|
|
40
|
+
return () => query.removeEventListener("change", onChange);
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
return reduced;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function useInViewport<T extends Element>(): [React.RefObject<T | null>, boolean] {
|
|
47
|
+
const ref = React.useRef<T | null>(null);
|
|
48
|
+
const [inView, setInView] = React.useState(true);
|
|
49
|
+
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
const node = ref.current;
|
|
52
|
+
if (!node || typeof IntersectionObserver === "undefined") return;
|
|
53
|
+
const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
|
|
54
|
+
threshold: 0,
|
|
55
|
+
});
|
|
56
|
+
observer.observe(node);
|
|
57
|
+
return () => observer.disconnect();
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
return [ref, inView];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type PosterWallTile = {
|
|
64
|
+
src?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type PosterWallProps = {
|
|
68
|
+
/** Background tiles. Desktop renders 48, `compact` renders 36 — shorter arrays are padded with placeholders. */
|
|
69
|
+
tiles?: PosterWallTile[];
|
|
70
|
+
/** `compact` is the bottom-aligned mobile treatment (fewer, larger tiles, a top-fading veil). */
|
|
71
|
+
variant?: "default" | "compact";
|
|
72
|
+
/** The overlay content — fully caller-composed. */
|
|
73
|
+
children?: React.ReactNode;
|
|
74
|
+
className?: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export function PosterWall({ tiles, variant = "default", children, className }: PosterWallProps) {
|
|
78
|
+
const isCompact = variant === "compact";
|
|
79
|
+
const tileCount = isCompact ? POSTER_WALL_COMPACT_TILE_COUNT : POSTER_WALL_DESKTOP_TILE_COUNT;
|
|
80
|
+
const slots = Array.from({ length: tileCount }, (_, index): PosterWallTile => tiles?.[index] ?? {});
|
|
81
|
+
|
|
82
|
+
const prefersReducedMotion = usePrefersReducedMotion();
|
|
83
|
+
const [wallRef, inView] = useInViewport<HTMLDivElement>();
|
|
84
|
+
const isSweepPaused = prefersReducedMotion || !inView;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
ref={wallRef}
|
|
89
|
+
className={cn(
|
|
90
|
+
"relative overflow-hidden rounded-[18px] bg-[oklch(0.22_0.012_60)]",
|
|
91
|
+
isCompact ? "h-[330px]" : "h-[420px]",
|
|
92
|
+
className
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
<style>{`
|
|
96
|
+
@keyframes ${POSTER_WALL_SWEEP_KEYFRAMES_ID} {
|
|
97
|
+
0% { transform: translateX(-30%); }
|
|
98
|
+
100% { transform: translateX(30%); }
|
|
99
|
+
}
|
|
100
|
+
`}</style>
|
|
101
|
+
|
|
102
|
+
<div
|
|
103
|
+
className={cn(
|
|
104
|
+
"absolute inset-0 grid auto-rows-fr gap-[2px] opacity-50",
|
|
105
|
+
isCompact ? "grid-cols-6" : "grid-cols-12"
|
|
106
|
+
)}
|
|
107
|
+
aria-hidden
|
|
108
|
+
>
|
|
109
|
+
{slots.map((tile, index) =>
|
|
110
|
+
tile.src ? (
|
|
111
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
112
|
+
<img
|
|
113
|
+
key={index}
|
|
114
|
+
src={tile.src}
|
|
115
|
+
alt=""
|
|
116
|
+
loading="lazy"
|
|
117
|
+
decoding="async"
|
|
118
|
+
className="h-full w-full object-cover"
|
|
119
|
+
/>
|
|
120
|
+
) : (
|
|
121
|
+
<div
|
|
122
|
+
key={index}
|
|
123
|
+
className="h-full w-full bg-[repeating-linear-gradient(45deg,color-mix(in_oklch,white_10%,transparent)_0px,color-mix(in_oklch,white_10%,transparent)_2px,transparent_2px,transparent_8px)]"
|
|
124
|
+
/>
|
|
125
|
+
)
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{/* Reduced motion and off-screen both resolve to "paused" via JS, not a
|
|
130
|
+
`motion-reduce:` class — the animation properties are inline styles
|
|
131
|
+
(so `animation-duration` can stay a plain string), and an inline
|
|
132
|
+
style always outranks a class on the same property. */}
|
|
133
|
+
<div
|
|
134
|
+
className="absolute -inset-[20%]"
|
|
135
|
+
style={{
|
|
136
|
+
background:
|
|
137
|
+
"radial-gradient(circle at 30% 50%, oklch(1 0 0 / 0.34) 0%, transparent 42%)",
|
|
138
|
+
animationName: POSTER_WALL_SWEEP_KEYFRAMES_ID,
|
|
139
|
+
animationDuration: "18s",
|
|
140
|
+
animationTimingFunction: "linear",
|
|
141
|
+
animationIterationCount: "infinite",
|
|
142
|
+
animationPlayState: isSweepPaused ? "paused" : "running",
|
|
143
|
+
}}
|
|
144
|
+
aria-hidden
|
|
145
|
+
/>
|
|
146
|
+
|
|
147
|
+
<div
|
|
148
|
+
className="absolute inset-0"
|
|
149
|
+
style={{
|
|
150
|
+
background: isCompact
|
|
151
|
+
? "linear-gradient(to top, oklch(0.2 0.01 60 / 0.93) 0%, oklch(0.2 0.01 60 / 0.7) 55%, oklch(0.2 0.01 60 / 0.35) 100%)"
|
|
152
|
+
: "linear-gradient(90deg, oklch(0.2 0.01 60 / 0.88) 0%, oklch(0.2 0.01 60 / 0.55) 55%, oklch(0.2 0.01 60 / 0.2) 100%)",
|
|
153
|
+
}}
|
|
154
|
+
aria-hidden
|
|
155
|
+
/>
|
|
156
|
+
|
|
157
|
+
<div
|
|
158
|
+
className={cn(
|
|
159
|
+
"relative flex h-full min-w-0 flex-col text-white",
|
|
160
|
+
isCompact ? "justify-end gap-[11px] p-5" : "justify-center gap-[15px] px-12"
|
|
161
|
+
)}
|
|
162
|
+
>
|
|
163
|
+
{children}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type PosterWallSkeletonProps = {
|
|
170
|
+
/** `compact` is the bottom-aligned mobile treatment (fewer, larger tiles, a top-fading veil). */
|
|
171
|
+
variant?: "default" | "compact";
|
|
172
|
+
className?: string;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** The tile grid at rest — the sweeping light never renders in the loading state. */
|
|
176
|
+
export function PosterWallSkeleton({ variant = "default", className }: PosterWallSkeletonProps) {
|
|
177
|
+
const isCompact = variant === "compact";
|
|
178
|
+
const tileCount = isCompact ? POSTER_WALL_COMPACT_TILE_COUNT : POSTER_WALL_DESKTOP_TILE_COUNT;
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div
|
|
182
|
+
aria-hidden="true"
|
|
183
|
+
className={cn(
|
|
184
|
+
"relative overflow-hidden rounded-[18px] bg-[oklch(0.22_0.012_60)]",
|
|
185
|
+
isCompact ? "h-[330px]" : "h-[420px]",
|
|
186
|
+
className
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
189
|
+
<div
|
|
190
|
+
className={cn(
|
|
191
|
+
"absolute inset-0 grid auto-rows-fr gap-[2px] opacity-50",
|
|
192
|
+
isCompact ? "grid-cols-6" : "grid-cols-12"
|
|
193
|
+
)}
|
|
194
|
+
>
|
|
195
|
+
{Array.from({ length: tileCount }, (_, index) => (
|
|
196
|
+
<Skeleton key={index} className="h-full w-full rounded-none bg-white/10" />
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div
|
|
201
|
+
className="absolute inset-0"
|
|
202
|
+
style={{
|
|
203
|
+
background: isCompact
|
|
204
|
+
? "linear-gradient(to top, oklch(0.2 0.01 60 / 0.93) 0%, oklch(0.2 0.01 60 / 0.7) 55%, oklch(0.2 0.01 60 / 0.35) 100%)"
|
|
205
|
+
: "linear-gradient(90deg, oklch(0.2 0.01 60 / 0.88) 0%, oklch(0.2 0.01 60 / 0.55) 55%, oklch(0.2 0.01 60 / 0.2) 100%)",
|
|
206
|
+
}}
|
|
207
|
+
/>
|
|
208
|
+
|
|
209
|
+
<div
|
|
210
|
+
className={cn(
|
|
211
|
+
"relative flex h-full min-w-0 flex-col",
|
|
212
|
+
isCompact ? "justify-end gap-[11px] p-5" : "justify-center gap-[15px] px-12"
|
|
213
|
+
)}
|
|
214
|
+
>
|
|
215
|
+
<Skeleton className={cn("bg-white/15", isCompact ? "h-[11px] w-40" : "h-3 w-52")} />
|
|
216
|
+
<Skeleton className={cn("bg-white/20", isCompact ? "h-7 w-64" : "h-10 w-96")} />
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|