|
| 1 | +import { Fragment, forwardRef, useRef } from "react"; |
| 2 | +import type { ShowcaseExample } from "~/lib/showcase.server"; |
| 3 | +import { showcaseExamples } from "~/lib/showcase.server"; |
| 4 | +import { clsx } from "clsx"; |
| 5 | +import { useHydrated } from "~/ui/primitives/utils"; |
| 6 | +import type { Route } from "./+types/_extras.showcase"; |
| 7 | + |
| 8 | +export const loader = async ({ request }: Route.LoaderArgs) => { |
| 9 | + let requestUrl = new URL(request.url); |
| 10 | + let siteUrl = requestUrl.protocol + "//" + requestUrl.host; |
| 11 | + |
| 12 | + return { siteUrl, showcaseExamples }; |
| 13 | +}; |
| 14 | + |
| 15 | +// Stolen from _marketing._index.tsx. eventually would like to replace |
| 16 | +export function meta({ data }: Route.MetaArgs) { |
| 17 | + let { siteUrl } = data; |
| 18 | + let title = "Remix Showcase"; |
| 19 | + let image = siteUrl ? `${siteUrl}/img/og.1.jpg` : null; |
| 20 | + let description = "See who is using Remix to build better websites."; |
| 21 | + |
| 22 | + return [ |
| 23 | + { title }, |
| 24 | + { name: "description", content: description }, |
| 25 | + { property: "og:url", content: `${siteUrl}/showcase` }, |
| 26 | + { property: "og:title", content: title }, |
| 27 | + { property: "og:description", content: description }, |
| 28 | + { property: "og:image", content: image }, |
| 29 | + { name: "twitter:card", content: "summary_large_image" }, |
| 30 | + { name: "twitter:creator", content: "@remix_run" }, |
| 31 | + { name: "twitter:site", content: "@remix_run" }, |
| 32 | + { name: "twitter:title", content: title }, |
| 33 | + { name: "twitter:description", content: description }, |
| 34 | + { name: "twitter:image", content: image }, |
| 35 | + ]; |
| 36 | +} |
| 37 | + |
| 38 | +export default function Showcase({ loaderData }: Route.ComponentProps) { |
| 39 | + // Might be a bit silly to declare here and then prop-drill, but was a little concerned about a needless useEffect+useState for every card |
| 40 | + let isHydrated = useHydrated(); |
| 41 | + |
| 42 | + return ( |
| 43 | + <main |
| 44 | + className="container mt-8 flex flex-1 flex-col items-center" |
| 45 | + tabIndex={-1} // is this every gonna be focused? just copy pasta |
| 46 | + > |
| 47 | + <div className="max-w-3xl text-center"> |
| 48 | + <h1 className="text-4xl font-bold md:text-5xl lg:text-6xl"> |
| 49 | + Remix Showcase |
| 50 | + </h1> |
| 51 | + <p className="mt-4 max-w-2xl text-lg font-light"> |
| 52 | + Checkout the companies, organizations, nonprofits, and indie |
| 53 | + developers building better websites with Remix |
| 54 | + </p> |
| 55 | + </div> |
| 56 | + <ul className="mt-8 grid w-full max-w-md grid-cols-1 gap-x-6 gap-y-10 self-center md:max-w-3xl md:grid-cols-2 lg:max-w-6xl lg:grid-cols-3 lg:gap-x-8"> |
| 57 | + {loaderData.showcaseExamples.map((example, i) => { |
| 58 | + let loading: ShowcaseTypes["loading"] = i < 6 ? "eager" : "lazy"; |
| 59 | + return ( |
| 60 | + <Fragment key={example.name}> |
| 61 | + <DesktopShowcase |
| 62 | + // Non-focusable since focusing on the anchor tag starts the |
| 63 | + // video -- need to ensure that this is fine for screen |
| 64 | + // readers, but I'm fairly confident the video is not critical |
| 65 | + // information and just visual flair so I don't think we're |
| 66 | + // providing an unusable or even bad experience to |
| 67 | + // screen-reader users |
| 68 | + isHydrated={isHydrated} |
| 69 | + loading={loading} |
| 70 | + {...example} |
| 71 | + /> |
| 72 | + <MobileShowcase |
| 73 | + isHydrated={isHydrated} |
| 74 | + loading={loading} |
| 75 | + {...example} |
| 76 | + /> |
| 77 | + </Fragment> |
| 78 | + ); |
| 79 | + })} |
| 80 | + </ul> |
| 81 | + </main> |
| 82 | + ); |
| 83 | +} |
| 84 | + |
| 85 | +type ShowcaseTypes = ShowcaseExample & { |
| 86 | + loading?: "lazy" | "eager"; |
| 87 | + isHydrated: boolean; |
| 88 | +}; |
| 89 | + |
| 90 | +function DesktopShowcase({ |
| 91 | + name, |
| 92 | + description, |
| 93 | + link, |
| 94 | + imgSrc, |
| 95 | + videoSrc, |
| 96 | + loading, |
| 97 | + isHydrated, |
| 98 | +}: ShowcaseTypes) { |
| 99 | + let videoRef = useRef<HTMLVideoElement | null>(null); |
| 100 | + |
| 101 | + return ( |
| 102 | + <li className="relative hidden overflow-hidden rounded-md border border-gray-100 shadow hover:shadow-blue-200 dark:border-gray-800 md:block"> |
| 103 | + <ShowcaseVideo |
| 104 | + ref={videoRef} |
| 105 | + videoSrc={videoSrc} |
| 106 | + poster={imgSrc} |
| 107 | + autoPlay={false} |
| 108 | + loading={loading} |
| 109 | + isHydrated={isHydrated} |
| 110 | + /> |
| 111 | + <ShowcaseDescription |
| 112 | + name={name} |
| 113 | + description={description} |
| 114 | + link={link} |
| 115 | + isHydrated={isHydrated} |
| 116 | + playVideo={() => videoRef.current?.play()} |
| 117 | + pauseVideo={() => videoRef.current?.pause()} |
| 118 | + /> |
| 119 | + </li> |
| 120 | + ); |
| 121 | +} |
| 122 | + |
| 123 | +function MobileShowcase({ |
| 124 | + name, |
| 125 | + description, |
| 126 | + link, |
| 127 | + imgSrc, |
| 128 | + isHydrated, |
| 129 | + loading, |
| 130 | +}: Omit<ShowcaseTypes, "videoSrc">) { |
| 131 | + return ( |
| 132 | + <li className="relative block overflow-hidden rounded-md border border-gray-100 shadow hover:shadow-blue-200 dark:border-gray-800 md:hidden"> |
| 133 | + <div className={"aspect-[4/3] object-cover object-top"}> |
| 134 | + <img |
| 135 | + className="max-h-full w-full max-w-full" |
| 136 | + width={800} |
| 137 | + height={600} |
| 138 | + alt="" |
| 139 | + src={imgSrc} |
| 140 | + loading={loading} |
| 141 | + /> |
| 142 | + </div> |
| 143 | + <ShowcaseDescription |
| 144 | + name={name} |
| 145 | + description={description} |
| 146 | + link={link} |
| 147 | + isHydrated={isHydrated} |
| 148 | + /> |
| 149 | + </li> |
| 150 | + ); |
| 151 | +} |
| 152 | + |
| 153 | +let ShowcaseVideo = forwardRef< |
| 154 | + HTMLVideoElement, |
| 155 | + Pick<ShowcaseTypes, "videoSrc" | "isHydrated" | "loading"> & |
| 156 | + React.VideoHTMLAttributes<HTMLVideoElement> |
| 157 | +>(({ videoSrc, className, isHydrated, loading, ...props }, ref) => { |
| 158 | + return ( |
| 159 | + <div className={clsx("aspect-[4/3] object-cover object-top", className)}> |
| 160 | + <video |
| 161 | + ref={ref} |
| 162 | + className="max-h-full w-full max-w-full" |
| 163 | + disablePictureInPicture |
| 164 | + disableRemotePlayback |
| 165 | + playsInline |
| 166 | + loop |
| 167 | + muted |
| 168 | + width={800} |
| 169 | + height={600} |
| 170 | + // Note: autoplay must be off for this strategy to work, if autoplay is turned on all assets will be downloaded automatically |
| 171 | + tabIndex={isHydrated ? -1 : 0} |
| 172 | + preload={loading === "eager" ? "auto" : "none"} |
| 173 | + {...props} |
| 174 | + > |
| 175 | + {["webm", "mp4"].map((ext) => ( |
| 176 | + <source |
| 177 | + key={ext} |
| 178 | + src={`${videoSrc}.${ext}`} |
| 179 | + type={`video/${ext}`} |
| 180 | + width={800} |
| 181 | + height={600} |
| 182 | + // avoid video assets downloading on mobile |
| 183 | + media="(min-width: 768px)" |
| 184 | + /> |
| 185 | + ))} |
| 186 | + </video> |
| 187 | + </div> |
| 188 | + ); |
| 189 | +}); |
| 190 | + |
| 191 | +ShowcaseVideo.displayName = "ShowcaseVideo"; |
| 192 | + |
| 193 | +function ShowcaseDescription({ |
| 194 | + description, |
| 195 | + link, |
| 196 | + name, |
| 197 | + isHydrated, |
| 198 | + playVideo, |
| 199 | + pauseVideo, |
| 200 | +}: Pick<ShowcaseTypes, "description" | "link" | "name" | "isHydrated"> & { |
| 201 | + playVideo?: () => void; |
| 202 | + pauseVideo?: () => void; |
| 203 | +}) { |
| 204 | + return ( |
| 205 | + <div |
| 206 | + className={clsx("p-4", { |
| 207 | + // relative position in combination with the inner span makes the whole |
| 208 | + // card clickable only after hydration do we want it to be the size of |
| 209 | + // the whole card, otherwise this will get in the way of controlling |
| 210 | + // the video |
| 211 | + relative: !isHydrated, |
| 212 | + })} |
| 213 | + > |
| 214 | + <h2 className="font-medium hover:text-blue-brand"> |
| 215 | + <a |
| 216 | + href={link} |
| 217 | + rel="noopener noreferrer" |
| 218 | + target="_blank" |
| 219 | + onFocus={playVideo} |
| 220 | + onBlur={pauseVideo} |
| 221 | + > |
| 222 | + <span |
| 223 | + onMouseOver={playVideo} |
| 224 | + onMouseOut={pauseVideo} |
| 225 | + className="absolute inset-0" |
| 226 | + /> |
| 227 | + |
| 228 | + {name} |
| 229 | + </a> |
| 230 | + </h2> |
| 231 | + <p className="pt-2 text-xs font-light">{description}</p> |
| 232 | + </div> |
| 233 | + ); |
| 234 | +} |
0 commit comments