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
This commit is contained in:
かっこかり
2024-12-12 01:31:29 +09:00
committed by GitHub
parent 48c5341644
commit de15f8e2d3
10 changed files with 88 additions and 42 deletions
+3 -1
View File
@@ -20,6 +20,7 @@ window.addEventListener('message', event => {
return return
} }
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const id = (event.data as {id: string}).id const id = (event.data as {id: string}).id
if (!id) { if (!id) {
return return
@@ -33,6 +34,7 @@ window.addEventListener('message', event => {
return return
} }
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const height = (event.data as {height: number}).height const height = (event.data as {height: number}).height
if (height) { if (height) {
embed.style.height = `${height}px` embed.style.height = `${height}px`
@@ -47,7 +49,7 @@ window.addEventListener('message', event => {
* @returns * @returns
*/ */
function scan(node = document) { 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++) { for (let i = 0; i < embeds.length; i++) {
const id = String(Math.random()).slice(2) const id = String(Math.random()).slice(2)
+17
View File
@@ -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')
})
}
+1 -1
View File
@@ -37,7 +37,7 @@ export function Container({
return ( return (
<div <div
ref={ref} 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={() => { onClick={() => {
if (ref.current && href) { if (ref.current && href) {
// forwardRef requires preact/compat - let's keep it simple // forwardRef requires preact/compat - let's keep it simple
+20 -14
View File
@@ -78,9 +78,9 @@ export function Embed({
return ( return (
<Link <Link
href={`/profile/${record.author.did}/post/${getRkey(record)}`} 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="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 <img
src={record.author.avatar} src={record.author.avatar}
style={isAuthorLabeled ? {filter: 'blur(1.5px)'} : undefined} style={isAuthorLabeled ? {filter: 'blur(1.5px)'} : undefined}
@@ -88,7 +88,7 @@ export function Embed({
</div> </div>
<p className="line-clamp-1 text-sm"> <p className="line-clamp-1 text-sm">
<span className="font-bold">{record.author.displayName}</span> <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} @{record.author.handle}
</span> </span>
</p> </p>
@@ -209,7 +209,7 @@ function Info({children}: {children: ComponentChildren}) {
return ( return (
<div className="w-full rounded-lg border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50"> <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" /> <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> </div>
) )
} }
@@ -308,7 +308,7 @@ function ExternalEmbed({
return ( return (
<Link <Link
href={content.external.uri} 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> disableTracking>
{content.external.thumb && ( {content.external.thumb && (
<img <img
@@ -317,11 +317,11 @@ function ExternalEmbed({
/> />
)} )}
<div className="py-3 px-4"> <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)} {toNiceDomain(content.external.uri)}
</p> </p>
<p className="font-semibold line-clamp-3">{content.external.title}</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} {content.external.description}
</p> </p>
</div> </div>
@@ -345,23 +345,29 @@ function GenericWithImageEmbed({
return ( return (
<Link <Link
href={href} 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"> <div className="flex gap-2.5 items-center">
{image ? ( {image ? (
<img <img
src={image} src={image}
alt={title} 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="w-8 h-8 rounded-md bg-brand shrink-0" />
)} )}
<div className="flex-1"> <div className="flex-1">
<p className="font-bold text-sm">{title}</p> <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>
</div> </div>
{description && <p className="text-textLight text-sm">{description}</p>} {description && (
<p className="text-textLight dark:text-textDimmed text-sm">
{description}
</p>
)}
</Link> </Link>
) )
} }
@@ -406,7 +412,7 @@ function StarterPackEmbed({
return ( return (
<Link <Link
href={starterPackHref} 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" /> <img src={imageUri} className="aspect-[1.91/1] object-cover" />
<div className="py-3 px-4"> <div className="py-3 px-4">
<div className="flex space-x-2 items-center"> <div className="flex space-x-2 items-center">
@@ -415,7 +421,7 @@ function StarterPackEmbed({
<p className="font-semibold leading-[21px]"> <p className="font-semibold leading-[21px]">
{content.record.name} {content.record.name}
</p> </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{' '} Starter pack by{' '}
{content.creator.displayName || `@${content.creator.handle}`} {content.creator.displayName || `@${content.creator.handle}`}
</p> </p>
@@ -425,7 +431,7 @@ function StarterPackEmbed({
<p className="text-sm mt-1">{content.record.description}</p> <p className="text-sm mt-1">{content.record.description}</p>
)} )}
{!!content.joinedAllTimeCount && content.joinedAllTimeCount > 50 && ( {!!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! {content.joinedAllTimeCount} users have joined!
</p> </p>
)} )}
+10 -8
View File
@@ -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-1 flex-col flex gap-2" lang={record?.langs?.[0]}>
<div className="flex gap-2.5 items-center cursor-pointer"> <div className="flex gap-2.5 items-center cursor-pointer">
<Link href={`/profile/${post.author.did}`} className="rounded-full"> <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 <img
src={post.author.avatar} src={post.author.avatar}
style={isAuthorLabeled ? {filter: 'blur(2.5px)'} : undefined} style={isAuthorLabeled ? {filter: 'blur(2.5px)'} : undefined}
@@ -53,7 +53,7 @@ export function Post({thread}: Props) {
</Link> </Link>
<Link <Link
href={`/profile/${post.author.did}`} 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> <p>@{post.author.handle}</p>
</Link> </Link>
</div> </div>
@@ -69,15 +69,15 @@ export function Post({thread}: Props) {
<Link href={href}> <Link href={href}>
<time <time
datetime={new Date(post.indexedAt).toISOString()} 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)} {niceDate(post.indexedAt)}
</time> </time>
</Link> </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 && ( {!!post.likeCount && (
<div className="flex items-center gap-2 cursor-pointer"> <div className="flex items-center gap-2 cursor-pointer">
<img src={likeIcon} className="w-5 h-5" /> <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)} {prettyNumber(post.likeCount)}
</p> </p>
</div> </div>
@@ -85,17 +85,19 @@ export function Post({thread}: Props) {
{!!post.repostCount && ( {!!post.repostCount && (
<div className="flex items-center gap-2 cursor-pointer"> <div className="flex items-center gap-2 cursor-pointer">
<img src={repostIcon} className="w-5 h-5" /> <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)} {prettyNumber(post.repostCount)}
</p> </p>
</div> </div>
)} )}
<div className="flex items-center gap-2 cursor-pointer"> <div className="flex items-center gap-2 cursor-pointer">
<img src={replyIcon} className="w-5 h-5" /> <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>
<div className="flex-1" /> <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 {post.replyCount
? `Read ${prettyNumber(post.replyCount)} ${ ? `Read ${prettyNumber(post.replyCount)} ${
post.replyCount > 1 ? 'replies' : 'reply' post.replyCount > 1 ? 'replies' : 'reply'
+4
View File
@@ -5,3 +5,7 @@
.break-word { .break-word {
word-break: break-word; word-break: break-word;
} }
:root {
color-scheme: light dark;
}
+18 -13
View File
@@ -6,6 +6,7 @@ import {useEffect, useMemo, useRef, useState} from 'preact/hooks'
import arrowBottom from '../../assets/arrowBottom_stroke2_corner0_rounded.svg' import arrowBottom from '../../assets/arrowBottom_stroke2_corner0_rounded.svg'
import logo from '../../assets/logo.svg' import logo from '../../assets/logo.svg'
import {initColorMode} from '../color-mode'
import {Container} from '../components/container' import {Container} from '../components/container'
import {Link} from '../components/link' import {Link} from '../components/link'
import {Post} from '../components/post' import {Post} from '../components/post'
@@ -21,6 +22,8 @@ export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
const root = document.getElementById('app') const root = document.getElementById('app')
if (!root) throw new Error('No root element') if (!root) throw new Error('No root element')
initColorMode()
const agent = new BskyAgent({ const agent = new BskyAgent({
service: 'https://public.api.bsky.app', service: 'https://public.api.bsky.app',
}) })
@@ -108,7 +111,7 @@ function LandingPage() {
}, [uri]) }, [uri])
return ( 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 <Link
href="https://bsky.social/about" href="https://bsky.social/about"
className="transition-transform hover:scale-110"> className="transition-transform hover:scale-110">
@@ -121,20 +124,22 @@ function LandingPage() {
type="text" type="text"
value={uri} value={uri}
onInput={e => setUri(e.currentTarget.value)} 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} placeholder={DEFAULT_POST}
/> />
<img src={arrowBottom} className="w-6" /> <img src={arrowBottom} className="w-6 dark:invert" />
{loading ? ( {loading ? (
<Skeleton /> <div className="w-full max-w-[600px]">
<Skeleton />
</div>
) : ( ) : (
<div className="w-full max-w-[600px] gap-8 flex flex-col"> <div className="w-full max-w-[600px] gap-8 flex flex-col">
{!error && thread && uri && <Snippet thread={thread} />} {!error && thread && uri && <Snippet thread={thread} />}
{!error && thread && <Post thread={thread} key={thread.post.uri} />} {!error && thread && <Post thread={thread} key={thread.post.uri} />}
{error && ( {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> <p className="text-red-500 text-center">{error}</p>
</div> </div>
)} )}
@@ -149,15 +154,15 @@ function Skeleton() {
<Container> <Container>
<div className="flex-1 flex-col flex gap-2 pb-8"> <div className="flex-1 flex-col flex gap-2 pb-8">
<div className="flex gap-2.5 items-center"> <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="flex-1">
<div className="bg-neutral-100 animate-pulse w-64 h-4 rounded" /> <div className="bg-neutral-100 dark:bg-slate-700 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-32 h-3 mt-1 rounded" />
</div> </div>
</div> </div>
<div className="w-full h-4 mt-2 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 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 rounded animate-pulse" /> <div className="w-3/4 h-4 bg-neutral-100 dark:bg-slate-700 rounded animate-pulse" />
</div> </div>
</Container> </Container>
) )
@@ -220,7 +225,7 @@ function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
ref={ref} ref={ref}
type="text" type="text"
value={snippet} 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 readOnly
autoFocus autoFocus
onFocus={() => { onFocus={() => {
@@ -228,7 +233,7 @@ function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
}} }}
/> />
<button <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={() => { onClick={() => {
ref.current?.focus() ref.current?.focus()
ref.current?.select() ref.current?.select()
+6 -3
View File
@@ -4,6 +4,7 @@ import {AppBskyFeedDefs, AtpAgent} from '@atproto/api'
import {h, render} from 'preact' import {h, render} from 'preact'
import logo from '../../assets/logo.svg' import logo from '../../assets/logo.svg'
import {initColorMode} from '../color-mode'
import {Container} from '../components/container' import {Container} from '../components/container'
import {Link} from '../components/link' import {Link} from '../components/link'
import {Post} from '../components/post' import {Post} from '../components/post'
@@ -21,6 +22,8 @@ if (!uri) {
throw new Error('No uri in path') throw new Error('No uri in path')
} }
initColorMode()
agent agent
.getPostThread({ .getPostThread({
uri, uri,
@@ -55,13 +58,13 @@ function PwiOptOut({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
<img src={logo} className="h-6" /> <img src={logo} className="h-6" />
</Link> </Link>
<div className="w-full py-12 gap-4 flex flex-col items-center"> <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 The author of this post has requested their posts not be displayed on
external sites. external sites.
</p> </p>
<Link <Link
href={href} 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 View on Bluesky
</Link> </Link>
</div> </div>
@@ -77,7 +80,7 @@ function ErrorMessage() {
className="transition-transform hover:scale-110 absolute top-4 right-4"> className="transition-transform hover:scale-110 absolute top-4 right-4">
<img src={logo} className="h-6" /> <img src={logo} className="h-6" />
</Link> </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. Post not found, it may have been deleted.
</p> </p>
</Container> </Container>
+8
View File
@@ -1,11 +1,19 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: ['variant', [
'&:is(.dark *):not(:is(.dark .light *))',
]],
theme: { theme: {
extend: { extend: {
colors: { colors: {
brand: 'rgb(10,122,255)', brand: 'rgb(10,122,255)',
brandLighten: 'rgb(32,139,254)',
textLight: 'rgb(66,87,108)', 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)',
}, },
}, },
}, },
+1 -2
View File
@@ -1,4 +1,3 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES5", "target": "ES5",
@@ -20,5 +19,5 @@
"jsxFragmentFactory": "Fragment", "jsxFragmentFactory": "Fragment",
"downlevelIteration": true "downlevelIteration": true
}, },
"include": ["src", "vite.config.ts"] "include": ["src", "snippet", "vite.config.ts"]
} }