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