StoriesWidget.tsx raw
1 import { XIcon } from "lucide-react";
2 import React from "react";
3 import { Link } from "react-router";
4 import useSWR from "swr";
5 import ExternalLink from "src/components/ExternalLink";
6 import { Button } from "src/components/ui/button";
7 import {
8 Card,
9 CardContent,
10 CardHeader,
11 CardTitle,
12 } from "src/components/ui/card";
13 import {
14 Dialog,
15 DialogClose,
16 DialogContent,
17 DialogDescription,
18 DialogTitle,
19 } from "src/components/ui/dialog";
20 import { localStorageKeys } from "src/constants";
21 import { cn } from "src/lib/utils";
22 import { swrFetcher } from "src/utils/swr";
23
24 type StoryCta = {
25 label: string;
26 url: string;
27 openInNewTab: boolean;
28 };
29
30 type Story = {
31 id: string;
32 title: string;
33 avatar: string;
34 videoId?: string;
35 cta?: StoryCta;
36 };
37
38 type StoryApiResponse = {
39 id: number;
40 title: string;
41 avatar: string;
42 videoId?: string;
43 cta?: StoryCta;
44 };
45
46 function youTubeEmbedUrl(videoId: string) {
47 return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&rel=0`;
48 }
49
50 function loadViewedStoryIds(): Set<string> {
51 try {
52 const raw = localStorage.getItem(localStorageKeys.homeStoriesViewed);
53 if (!raw) {
54 return new Set();
55 }
56 const parsed = JSON.parse(raw) as unknown;
57 if (!Array.isArray(parsed)) {
58 return new Set();
59 }
60 return new Set(parsed.filter((id): id is string => typeof id === "string"));
61 } catch {
62 return new Set();
63 }
64 }
65
66 function persistViewedStoryIds(ids: Set<string>) {
67 try {
68 localStorage.setItem(
69 localStorageKeys.homeStoriesViewed,
70 JSON.stringify([...ids])
71 );
72 } catch {
73 // ignore quota / private mode
74 }
75 }
76
77 function StoryAvatar({ story, viewed }: { story: Story; viewed: boolean }) {
78 return (
79 <div
80 className={cn(
81 "relative box-border flex size-16 shrink-0 items-center justify-center rounded-full border-2 p-0.5",
82 viewed ? "border-accent" : "border-primary"
83 )}
84 >
85 <div className="relative flex size-full items-center justify-center overflow-hidden rounded-full bg-white dark:bg-muted">
86 <img
87 src={story.avatar}
88 alt={`${story.title} story`}
89 className="size-full rounded-full object-cover"
90 />
91 </div>
92 </div>
93 );
94 }
95
96 export function StoriesWidget() {
97 const { data, error, isLoading } = useSWR<StoryApiResponse[]>(
98 "/api/alby/stories",
99 swrFetcher
100 );
101 const [activeStory, setActiveStory] = React.useState<Story | null>(null);
102 const [viewedIds, setViewedIds] =
103 React.useState<Set<string>>(loadViewedStoryIds);
104
105 const stories = React.useMemo<Story[]>(
106 () =>
107 error || !data
108 ? []
109 : data.map((story) => ({
110 id: String(story.id),
111 title: story.title,
112 avatar: story.avatar,
113 videoId: story.videoId,
114 cta: story.cta,
115 })),
116 [data, error]
117 );
118
119 const markStoryViewed = React.useCallback((storyId: string) => {
120 setViewedIds((prev) => {
121 if (prev.has(storyId)) {
122 return prev;
123 }
124 const next = new Set(prev);
125 next.add(storyId);
126 persistViewedStoryIds(next);
127 return next;
128 });
129 }, []);
130
131 if (!isLoading && stories.length === 0) {
132 return null;
133 }
134
135 return (
136 <>
137 <Card className="overflow-hidden rounded-[14px] shadow-none">
138 <CardHeader className="px-6 pb-0">
139 <CardTitle className="text-base font-semibold">Stories</CardTitle>
140 </CardHeader>
141 <CardContent className="px-0 py-0">
142 <div className="flex gap-3 overflow-x-auto px-6 pb-1">
143 {isLoading && (
144 <span className="text-sm text-muted-foreground">
145 Loading stories...
146 </span>
147 )}
148 {!isLoading &&
149 stories.map((story) => {
150 const viewed = viewedIds.has(story.id);
151 return (
152 <button
153 key={story.id}
154 type="button"
155 onClick={() => {
156 markStoryViewed(story.id);
157 setActiveStory(story);
158 }}
159 className="flex w-21 shrink-0 flex-col items-center gap-2 text-center"
160 >
161 <StoryAvatar story={story} viewed={viewed} />
162 <span
163 className={cn(
164 "w-full truncate text-xs leading-tight",
165 viewed
166 ? "font-medium text-muted-foreground"
167 : "font-semibold text-foreground"
168 )}
169 >
170 {story.title}
171 </span>
172 </button>
173 );
174 })}
175 </div>
176 </CardContent>
177 </Card>
178
179 <Dialog
180 open={!!activeStory}
181 onOpenChange={(open) => !open && setActiveStory(null)}
182 >
183 <DialogContent
184 showCloseButton={false}
185 className="w-[95vw] max-w-[min(95vw,calc((90vh-80px)*16/9))] sm:max-w-[min(95vw,calc((90vh-80px)*16/9))] max-h-[90vh] overflow-hidden border-0 bg-zinc-950 p-0 text-white sm:rounded-2xl"
186 >
187 {activeStory && (
188 <div className="flex flex-col">
189 <DialogTitle className="sr-only">{activeStory.title}</DialogTitle>
190 <DialogDescription className="sr-only">
191 Watch the latest update
192 </DialogDescription>
193
194 {activeStory.videoId && (
195 <div className="relative aspect-video w-full overflow-hidden rounded-t-2xl bg-black [transform:translateZ(0)]">
196 <iframe
197 className="absolute inset-0 size-full"
198 src={youTubeEmbedUrl(activeStory.videoId)}
199 title={activeStory.title}
200 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
201 // the server's global no-referrer policy breaks YouTube
202 // embeds (error 153); send the origin for this frame only
203 referrerPolicy="strict-origin-when-cross-origin"
204 allowFullScreen
205 />
206 <DialogClose asChild>
207 <Button
208 type="button"
209 variant="ghost"
210 size="icon"
211 className="absolute right-3 top-3 z-10 rounded-full bg-black/60 text-white backdrop-blur hover:bg-black/80 hover:text-white"
212 >
213 <XIcon className="size-5" />
214 <span className="sr-only">Close story</span>
215 </Button>
216 </DialogClose>
217 </div>
218 )}
219
220 {activeStory.videoId && (
221 <div className="flex items-center justify-between gap-3 px-6 py-4">
222 <div className="min-w-0">
223 <div className="truncate text-base font-semibold text-white">
224 {activeStory.title}
225 </div>
226 </div>
227 {activeStory.cta && (
228 <div className="flex items-center gap-2">
229 {activeStory.cta.openInNewTab ? (
230 <ExternalLink
231 to={activeStory.cta.url}
232 className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
233 >
234 {activeStory.cta.label}
235 </ExternalLink>
236 ) : (
237 <Link
238 to={activeStory.cta.url}
239 className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
240 >
241 {activeStory.cta.label}
242 </Link>
243 )}
244 </div>
245 )}
246 </div>
247 )}
248 </div>
249 )}
250 </DialogContent>
251 </Dialog>
252 </>
253 );
254 }
255