about summary refs log tree commit diff
path: root/bskyembed
diff options
context:
space:
mode:
authorかっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>2024-12-12 01:31:29 +0900
committerGitHub <noreply@github.com>2024-12-11 16:31:29 +0000
commitde15f8e2d3264b9ffb18c3a089612ef604eb87b8 (patch)
treeae5419e798d55b978596ca7a396fdb395d2cf4fc /bskyembed
parent48c5341644935b103fa81c91b23391f7b7c8a572 (diff)
downloadvoidsky-de15f8e2d3264b9ffb18c3a089612ef604eb87b8.tar.zst
feat(embed): Add support for dark mode (#6912)
* feat(embed): Support dark mode (wip)

* finishing up the implementation

* fix tailwind color selector

* tweak design

* refactor: unify types

* fix

* fix english grammar

* refactor: unify types

* tweak design

* remove the customization part
Diffstat (limited to 'bskyembed')
-rw-r--r--bskyembed/snippet/embed.ts4
-rw-r--r--bskyembed/src/color-mode.ts17
-rw-r--r--bskyembed/src/components/container.tsx2
-rw-r--r--bskyembed/src/components/embed.tsx34
-rw-r--r--bskyembed/src/components/post.tsx18
-rw-r--r--bskyembed/src/index.css4
-rw-r--r--bskyembed/src/screens/landing.tsx31
-rw-r--r--bskyembed/src/screens/post.tsx9
-rw-r--r--bskyembed/tailwind.config.cjs8
-rw-r--r--bskyembed/tsconfig.json3
10 files changed, 88 insertions, 42 deletions
diff --git a/bskyembed/snippet/embed.ts b/bskyembed/snippet/embed.ts
index 380cda5fb..3c1b14b95 100644
--- a/bskyembed/snippet/embed.ts
+++ b/bskyembed/snippet/embed.ts
@@ -20,6 +20,7 @@ window.addEventListener('message', event => {
     return
   }
 
+  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
   const id = (event.data as {id: string}).id
   if (!id) {
     return
@@ -33,6 +34,7 @@ window.addEventListener('message', event => {
     return
   }
 
+  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
   const height = (event.data as {height: number}).height
   if (height) {
     embed.style.height = `${height}px`
@@ -47,7 +49,7 @@ window.addEventListener('message', event => {
  * @returns
  */
 function scan(node = document) {
-  const embeds = node.querySelectorAll('[data-bluesky-uri]')
+  const embeds = node.querySelectorAll<HTMLIFrameElement>('[data-bluesky-uri]')
 
   for (let i = 0; i < embeds.length; i++) {
     const id = String(Math.random()).slice(2)
diff --git a/bskyembed/src/color-mode.ts b/bskyembed/src/color-mode.ts
new file mode 100644
index 000000000..2b392c617
--- /dev/null
+++ b/bskyembed/src/color-mode.ts
@@ -0,0 +1,17 @@
+export function applyTheme(theme: 'light' | 'dark') {
+  document.documentElement.classList.remove('light', 'dark')
+  document.documentElement.classList.add(theme)
+}
+
+export function initColorMode() {
+  applyTheme(
+    window.matchMedia('(prefers-color-scheme: dark)').matches
+      ? 'dark'
+      : 'light',
+  )
+  window
+    .matchMedia('(prefers-color-scheme: dark)')
+    .addEventListener('change', mql => {
+      applyTheme(mql.matches ? 'dark' : 'light')
+    })
+}
diff --git a/bskyembed/src/components/container.tsx b/bskyembed/src/components/container.tsx
index 5b1b2b7fb..8e142a25b 100644
--- a/bskyembed/src/components/container.tsx
+++ b/bskyembed/src/components/container.tsx
@@ -37,7 +37,7 @@ export function Container({
   return (
     <div
       ref={ref}
-      className="w-full bg-white hover:bg-neutral-50 relative transition-colors max-w-[600px] min-w-[300px] flex border rounded-xl"
+      className="w-full bg-white text-black hover:bg-neutral-50 dark:bg-dimmedBg dark:hover:bg-dimmedBgLighten relative transition-colors max-w-[600px] min-w-[300px] flex border dark:border-slate-600 dark:text-slate-200 rounded-xl"
       onClick={() => {
         if (ref.current && href) {
           // forwardRef requires preact/compat - let's keep it simple
diff --git a/bskyembed/src/components/embed.tsx b/bskyembed/src/components/embed.tsx
index 74eacf16d..20ffcb2b2 100644
--- a/bskyembed/src/components/embed.tsx
+++ b/bskyembed/src/components/embed.tsx
@@ -78,9 +78,9 @@ export function Embed({
         return (
           <Link
             href={`/profile/${record.author.did}/post/${getRkey(record)}`}
-            className="transition-colors hover:bg-neutral-100 border rounded-lg p-2 gap-1.5 w-full flex flex-col">
+            className="transition-colors hover:bg-neutral-100 dark:hover:bg-slate-700 border dark:border-slate-600 rounded-lg p-2 gap-1.5 w-full flex flex-col">
             <div className="flex gap-1.5 items-center">
-              <div className="w-4 h-4 overflow-hidden rounded-full bg-neutral-300 shrink-0">
+              <div className="w-4 h-4 overflow-hidden rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
                 <img
                   src={record.author.avatar}
                   style={isAuthorLabeled ? {filter: 'blur(1.5px)'} : undefined}
@@ -88,7 +88,7 @@ export function Embed({
               </div>
               <p className="line-clamp-1 text-sm">
                 <span className="font-bold">{record.author.displayName}</span>
-                <span className="text-textLight ml-1">
+                <span className="text-textLight dark:text-textDimmed ml-1">
                   @{record.author.handle}
                 </span>
               </p>
@@ -209,7 +209,7 @@ function Info({children}: {children: ComponentChildren}) {
   return (
     <div className="w-full rounded-lg border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50">
       <img src={infoIcon} className="w-4 h-4 shrink-0 mt-0.5" />
-      <p className="text-sm text-textLight">{children}</p>
+      <p className="text-sm text-textLight dark:text-textDimmed">{children}</p>
     </div>
   )
 }
@@ -308,7 +308,7 @@ function ExternalEmbed({
   return (
     <Link
       href={content.external.uri}
-      className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch"
+      className="w-full rounded-lg overflow-hidden border dark:border-slate-600 flex flex-col items-stretch"
       disableTracking>
       {content.external.thumb && (
         <img
@@ -317,11 +317,11 @@ function ExternalEmbed({
         />
       )}
       <div className="py-3 px-4">
-        <p className="text-sm text-textLight line-clamp-1">
+        <p className="text-sm text-textLight dark:text-textDimmed line-clamp-1">
           {toNiceDomain(content.external.uri)}
         </p>
         <p className="font-semibold line-clamp-3">{content.external.title}</p>
-        <p className="text-sm text-textLight line-clamp-2 mt-0.5">
+        <p className="text-sm text-textLight dark:text-textDimmed line-clamp-2 mt-0.5">
           {content.external.description}
         </p>
       </div>
@@ -345,23 +345,29 @@ function GenericWithImageEmbed({
   return (
     <Link
       href={href}
-      className="w-full rounded-lg border py-2 px-3 flex flex-col gap-2">
+      className="w-full rounded-lg border dark:border-slate-600 py-2 px-3 flex flex-col gap-2">
       <div className="flex gap-2.5 items-center">
         {image ? (
           <img
             src={image}
             alt={title}
-            className="w-8 h-8 rounded-md bg-neutral-300 shrink-0"
+            className="w-8 h-8 rounded-md bg-neutral-300 dark:bg-slate-700 shrink-0"
           />
         ) : (
           <div className="w-8 h-8 rounded-md bg-brand shrink-0" />
         )}
         <div className="flex-1">
           <p className="font-bold text-sm">{title}</p>
-          <p className="text-textLight text-sm">{subtitle}</p>
+          <p className="text-textLight dark:text-textDimmed text-sm">
+            {subtitle}
+          </p>
         </div>
       </div>
-      {description && <p className="text-textLight text-sm">{description}</p>}
+      {description && (
+        <p className="text-textLight dark:text-textDimmed text-sm">
+          {description}
+        </p>
+      )}
     </Link>
   )
 }
@@ -406,7 +412,7 @@ function StarterPackEmbed({
   return (
     <Link
       href={starterPackHref}
-      className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch">
+      className="w-full rounded-lg overflow-hidden border dark:border-slate-600 flex flex-col items-stretch">
       <img src={imageUri} className="aspect-[1.91/1] object-cover" />
       <div className="py-3 px-4">
         <div className="flex space-x-2 items-center">
@@ -415,7 +421,7 @@ function StarterPackEmbed({
             <p className="font-semibold leading-[21px]">
               {content.record.name}
             </p>
-            <p className="text-sm text-textLight line-clamp-2 leading-[18px]">
+            <p className="text-sm text-textLight dark:text-textDimmed line-clamp-2 leading-[18px]">
               Starter pack by{' '}
               {content.creator.displayName || `@${content.creator.handle}`}
             </p>
@@ -425,7 +431,7 @@ function StarterPackEmbed({
           <p className="text-sm mt-1">{content.record.description}</p>
         )}
         {!!content.joinedAllTimeCount && content.joinedAllTimeCount > 50 && (
-          <p className="text-sm font-semibold text-textLight mt-1">
+          <p className="text-sm font-semibold text-textLight dark:text-textDimmed mt-1">
             {content.joinedAllTimeCount} users have joined!
           </p>
         )}
diff --git a/bskyembed/src/components/post.tsx b/bskyembed/src/components/post.tsx
index 4db5eeb45..26945eb69 100644
--- a/bskyembed/src/components/post.tsx
+++ b/bskyembed/src/components/post.tsx
@@ -38,7 +38,7 @@ export function Post({thread}: Props) {
       <div className="flex-1 flex-col flex gap-2" lang={record?.langs?.[0]}>
         <div className="flex gap-2.5 items-center cursor-pointer">
           <Link href={`/profile/${post.author.did}`} className="rounded-full">
-            <div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-300 shrink-0">
+            <div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
               <img
                 src={post.author.avatar}
                 style={isAuthorLabeled ? {filter: 'blur(2.5px)'} : undefined}
@@ -53,7 +53,7 @@ export function Post({thread}: Props) {
             </Link>
             <Link
               href={`/profile/${post.author.did}`}
-              className="text-[15px] text-textLight hover:underline line-clamp-1">
+              className="text-[15px] text-textLight dark:text-textDimmed hover:underline line-clamp-1">
               <p>@{post.author.handle}</p>
             </Link>
           </div>
@@ -69,15 +69,15 @@ export function Post({thread}: Props) {
         <Link href={href}>
           <time
             datetime={new Date(post.indexedAt).toISOString()}
-            className="text-textLight mt-1 text-sm hover:underline">
+            className="text-textLight dark:text-textDimmed mt-1 text-sm hover:underline">
             {niceDate(post.indexedAt)}
           </time>
         </Link>
-        <div className="border-t w-full pt-2.5 flex items-center gap-5 text-sm cursor-pointer">
+        <div className="border-t dark:border-slate-600 w-full pt-2.5 flex items-center gap-5 text-sm cursor-pointer">
           {!!post.likeCount && (
             <div className="flex items-center gap-2 cursor-pointer">
               <img src={likeIcon} className="w-5 h-5" />
-              <p className="font-bold text-neutral-500 mb-px">
+              <p className="font-bold text-neutral-500 dark:text-neutral-300 mb-px">
                 {prettyNumber(post.likeCount)}
               </p>
             </div>
@@ -85,17 +85,19 @@ export function Post({thread}: Props) {
           {!!post.repostCount && (
             <div className="flex items-center gap-2 cursor-pointer">
               <img src={repostIcon} className="w-5 h-5" />
-              <p className="font-bold text-neutral-500 mb-px">
+              <p className="font-bold text-neutral-500 dark:text-neutral-300 mb-px">
                 {prettyNumber(post.repostCount)}
               </p>
             </div>
           )}
           <div className="flex items-center gap-2 cursor-pointer">
             <img src={replyIcon} className="w-5 h-5" />
-            <p className="font-bold text-neutral-500 mb-px">Reply</p>
+            <p className="font-bold text-neutral-500 dark:text-neutral-300 mb-px">
+              Reply
+            </p>
           </div>
           <div className="flex-1" />
-          <p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
+          <p className="cursor-pointer text-brand dark:text-brandLighten font-bold hover:underline hidden min-[450px]:inline">
             {post.replyCount
               ? `Read ${prettyNumber(post.replyCount)} ${
                   post.replyCount > 1 ? 'replies' : 'reply'
diff --git a/bskyembed/src/index.css b/bskyembed/src/index.css
index 22b2b8be5..289e34cf0 100644
--- a/bskyembed/src/index.css
+++ b/bskyembed/src/index.css
@@ -5,3 +5,7 @@
 .break-word {
   word-break: break-word;
 }
+
+:root {
+  color-scheme: light dark;
+}
diff --git a/bskyembed/src/screens/landing.tsx b/bskyembed/src/screens/landing.tsx
index a9e08cd3f..a3448e90a 100644
--- a/bskyembed/src/screens/landing.tsx
+++ b/bskyembed/src/screens/landing.tsx
@@ -6,6 +6,7 @@ import {useEffect, useMemo, useRef, useState} from 'preact/hooks'
 
 import arrowBottom from '../../assets/arrowBottom_stroke2_corner0_rounded.svg'
 import logo from '../../assets/logo.svg'
+import {initColorMode} from '../color-mode'
 import {Container} from '../components/container'
 import {Link} from '../components/link'
 import {Post} from '../components/post'
@@ -21,6 +22,8 @@ export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
 const root = document.getElementById('app')
 if (!root) throw new Error('No root element')
 
+initColorMode()
+
 const agent = new BskyAgent({
   service: 'https://public.api.bsky.app',
 })
@@ -108,7 +111,7 @@ function LandingPage() {
   }, [uri])
 
   return (
-    <main className="w-full min-h-screen flex flex-col items-center gap-8 py-14 px-4 md:pt-32">
+    <main className="w-full min-h-screen flex flex-col items-center gap-8 py-14 px-4 md:pt-32 dark:bg-dimmedBgDarken dark:text-slate-200">
       <Link
         href="https://bsky.social/about"
         className="transition-transform hover:scale-110">
@@ -121,20 +124,22 @@ function LandingPage() {
         type="text"
         value={uri}
         onInput={e => setUri(e.currentTarget.value)}
-        className="border rounded-lg py-3 w-full max-w-[600px] px-4"
+        className="border rounded-lg py-3 w-full max-w-[600px] px-4 dark:bg-dimmedBg dark:border-slate-500"
         placeholder={DEFAULT_POST}
       />
 
-      <img src={arrowBottom} className="w-6" />
+      <img src={arrowBottom} className="w-6 dark:invert" />
 
       {loading ? (
-        <Skeleton />
+        <div className="w-full max-w-[600px]">
+          <Skeleton />
+        </div>
       ) : (
         <div className="w-full max-w-[600px] gap-8 flex flex-col">
           {!error && thread && uri && <Snippet thread={thread} />}
           {!error && thread && <Post thread={thread} key={thread.post.uri} />}
           {error && (
-            <div className="w-full border border-red-500 bg-red-50 px-4 py-3 rounded-lg">
+            <div className="w-full border border-red-500 bg-red-500/10 px-4 py-3 rounded-lg">
               <p className="text-red-500 text-center">{error}</p>
             </div>
           )}
@@ -149,15 +154,15 @@ function Skeleton() {
     <Container>
       <div className="flex-1 flex-col flex gap-2 pb-8">
         <div className="flex gap-2.5 items-center">
-          <div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-100 shrink-0 animate-pulse" />
+          <div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-100 dark:bg-slate-700 shrink-0 animate-pulse" />
           <div className="flex-1">
-            <div className="bg-neutral-100 animate-pulse w-64 h-4 rounded" />
-            <div className="bg-neutral-100 animate-pulse w-32 h-3 mt-1 rounded" />
+            <div className="bg-neutral-100 dark:bg-slate-700 animate-pulse w-64 h-4 rounded" />
+            <div className="bg-neutral-100 dark:bg-slate-700 animate-pulse w-32 h-3 mt-1 rounded" />
           </div>
         </div>
-        <div className="w-full h-4 mt-2 bg-neutral-100 rounded animate-pulse" />
-        <div className="w-5/6 h-4 bg-neutral-100 rounded animate-pulse" />
-        <div className="w-3/4 h-4 bg-neutral-100 rounded animate-pulse" />
+        <div className="w-full h-4 mt-2 bg-neutral-100 dark:bg-slate-700 rounded animate-pulse" />
+        <div className="w-5/6 h-4 bg-neutral-100 dark:bg-slate-700 rounded animate-pulse" />
+        <div className="w-3/4 h-4 bg-neutral-100 dark:bg-slate-700 rounded animate-pulse" />
       </div>
     </Container>
   )
@@ -220,7 +225,7 @@ function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
         ref={ref}
         type="text"
         value={snippet}
-        className="border rounded-lg py-3 w-full px-4"
+        className="border rounded-lg py-3 w-full px-4 dark:bg-dimmedBg dark:border-slate-500"
         readOnly
         autoFocus
         onFocus={() => {
@@ -228,7 +233,7 @@ function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
         }}
       />
       <button
-        className="rounded-lg bg-brand text-white color-white py-3 px-4 whitespace-nowrap min-w-28"
+        className="rounded-lg bg-brand text-white py-3 px-4 whitespace-nowrap min-w-28"
         onClick={() => {
           ref.current?.focus()
           ref.current?.select()
diff --git a/bskyembed/src/screens/post.tsx b/bskyembed/src/screens/post.tsx
index 6ccf10a79..1764442b7 100644
--- a/bskyembed/src/screens/post.tsx
+++ b/bskyembed/src/screens/post.tsx
@@ -4,6 +4,7 @@ import {AppBskyFeedDefs, AtpAgent} from '@atproto/api'
 import {h, render} from 'preact'
 
 import logo from '../../assets/logo.svg'
+import {initColorMode} from '../color-mode'
 import {Container} from '../components/container'
 import {Link} from '../components/link'
 import {Post} from '../components/post'
@@ -21,6 +22,8 @@ if (!uri) {
   throw new Error('No uri in path')
 }
 
+initColorMode()
+
 agent
   .getPostThread({
     uri,
@@ -55,13 +58,13 @@ function PwiOptOut({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
         <img src={logo} className="h-6" />
       </Link>
       <div className="w-full py-12 gap-4 flex flex-col items-center">
-        <p className="max-w-80 text-center w-full text-textLight">
+        <p className="max-w-80 text-center w-full text-textLight dark:text-textDimmed">
           The author of this post has requested their posts not be displayed on
           external sites.
         </p>
         <Link
           href={href}
-          className="max-w-80 rounded-lg bg-brand text-white color-white text-center py-1 px-4 w-full mx-auto">
+          className="max-w-80 rounded-lg bg-brand text-white text-center py-1 px-4 w-full mx-auto">
           View on Bluesky
         </Link>
       </div>
@@ -77,7 +80,7 @@ function ErrorMessage() {
         className="transition-transform hover:scale-110 absolute top-4 right-4">
         <img src={logo} className="h-6" />
       </Link>
-      <p className="my-16 text-center w-full text-textLight">
+      <p className="my-16 text-center w-full text-textLight dark:text-textDimmed">
         Post not found, it may have been deleted.
       </p>
     </Container>
diff --git a/bskyembed/tailwind.config.cjs b/bskyembed/tailwind.config.cjs
index 092e8c2cb..0e9f5b8ea 100644
--- a/bskyembed/tailwind.config.cjs
+++ b/bskyembed/tailwind.config.cjs
@@ -1,11 +1,19 @@
 /** @type {import('tailwindcss').Config} */
 module.exports = {
   content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
+  darkMode: ['variant', [
+    '&:is(.dark *):not(:is(.dark .light *))',
+  ]],
   theme: {
     extend: {
       colors: {
         brand: 'rgb(10,122,255)',
+        brandLighten: 'rgb(32,139,254)',
         textLight: 'rgb(66,87,108)',
+        textDimmed: 'rgb(174,187,201)',
+        dimmedBgLighten: 'rgb(30,41,54)',
+        dimmedBg: 'rgb(22,30,39)',
+        dimmedBgDarken: 'rgb(18,25,32)',
       },
     },
   },
diff --git a/bskyembed/tsconfig.json b/bskyembed/tsconfig.json
index b3b6055cc..f11db03e6 100644
--- a/bskyembed/tsconfig.json
+++ b/bskyembed/tsconfig.json
@@ -1,4 +1,3 @@
-
 {
   "compilerOptions": {
     "target": "ES5",
@@ -20,5 +19,5 @@
     "jsxFragmentFactory": "Fragment",
     "downlevelIteration": true
   },
-  "include": ["src", "vite.config.ts"]
+  "include": ["src", "snippet", "vite.config.ts"]
 }