From f671efd63f4df9f744f6596760c88fb32c8003cc Mon Sep 17 00:00:00 2001 From: Alexander Heimbuch Date: Sun, 12 Jan 2025 15:22:45 +0100 Subject: [PATCH] feat(page): use primaryColor --- apps/page/src/lib/dns-record.ts | 45 ++++++++++++------- apps/page/src/lib/get-image-color.ts | 26 +++++------ apps/page/src/logic/sagas/index.ts | 3 +- apps/page/src/logic/sagas/layout.sagas.ts | 39 ++++++++++++---- .../src/logic/store/stores/theme.store.ts | 9 +++- apps/page/src/middleware/request-param.ts | 23 ++++------ apps/page/src/middleware/store.ts | 7 ++- 7 files changed, 95 insertions(+), 57 deletions(-) diff --git a/apps/page/src/lib/dns-record.ts b/apps/page/src/lib/dns-record.ts index 3d21cce4..11ade627 100644 --- a/apps/page/src/lib/dns-record.ts +++ b/apps/page/src/lib/dns-record.ts @@ -1,21 +1,24 @@ -import dns from 'node:dns'; import { get, noop } from 'lodash-es'; import type { APIContext } from 'astro'; import extractDomain from 'extract-domain'; import { safeParse } from './json'; type FeedData = { feed: string | null; primary_color: string | null }; +type DnsAnswer = { + name: string; + type: number; + TTL: number; + data: string; +}; -const getDnsRecords = (hostname: string): Promise => - new Promise((resolve, reject) => { - dns.resolve(hostname, 'TXT', (err, records) => { - if (err) { - return reject(err); - } - - return resolve(records[0]); - }); - }); +const getDnsRecords = async (hostname: string): Promise => + fetch(`https://cloudflare-dns.com/dns-query?name=${hostname}&type=TXT`, { + headers: { + Accept: 'application/dns-json' + } + }) + .then((res) => res.json()) + .then((result) => get(result, ['Answer'], [])); const getStore = (context: APIContext): KVNamespace => get(context, ['locals', 'runtime', 'env', 'CUSTOM_DOMAINS'], { @@ -32,10 +35,22 @@ export const extractDnsData = async (context: APIContext): Promise => let result = await store.get(entryName); if (!result) { - result = await getDnsRecords(entryName).then( - ([result]) => result, - () => null - ); + result = await getDnsRecords(entryName) + .then( + ([result]) => get(result, 'data', null), + () => null + ) + .then((result) => { + if (!result) { + return null; + } + + try { + return atob(result.replace(/['"]+/g, '')); + } catch (err) { + return null; + } + }); } if (!result) { diff --git a/apps/page/src/lib/get-image-color.ts b/apps/page/src/lib/get-image-color.ts index cf2cdc4f..bdb298ac 100644 --- a/apps/page/src/lib/get-image-color.ts +++ b/apps/page/src/lib/get-image-color.ts @@ -1,11 +1,13 @@ import quantize from 'quantize'; -import { isDark } from 'farbraum'; import { type rgbColor } from '../types/color.types'; import ndarray from 'ndarray'; const fetchImage = async ( imageUrl: string -): Promise<{ data: ArrayBuffer; dimensions: { width: number; height: number } }> => +): Promise<{ + data: Uint8ClampedArray; + dimensions: { width: number; height: number }; +}> => new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'Anonymous'; @@ -30,7 +32,7 @@ const parseImage = ({ data, dimensions }: { - data: ArrayBuffer; + data: Uint8ClampedArray; dimensions: { width: number; height: number }; }): ndarray.NdArray => ndarray( @@ -55,29 +57,23 @@ const convertToPixels = (pixels: ndarray.NdArray): quantize.RgbPixel[] => { return result; }; -const extractColors = ( - pixels: quantize.RgbPixel[] -): { primaryColor: rgbColor | null; complementaryColor: rgbColor | null } => { +const extractColors = (pixels: quantize.RgbPixel[]): rgbColor | null => { const colorPalette = quantize(pixels, 5); if (!colorPalette) { - return { primaryColor: null, complementaryColor: null }; + return null; } const [primaryColor] = colorPalette.palette(); - const complementaryColor: rgbColor = isDark(primaryColor) ? [240, 240, 240] : [1, 1, 1]; - return { primaryColor, complementaryColor }; + return primaryColor; }; -const getImageColors = async ( - src: string -): Promise<{ primaryColor: rgbColor | null; complementaryColor: rgbColor | null }> => +const getImagePrimaryColor = async (src: string): Promise => fetchImage(src) .then(parseImage) .then(convertToPixels) .then(extractColors) + .catch(() => null); - .catch(() => ({ primaryColor: null, complementaryColor: null })); - -export default getImageColors; +export default getImagePrimaryColor; diff --git a/apps/page/src/logic/sagas/index.ts b/apps/page/src/logic/sagas/index.ts index 70f4ec7d..a4a15509 100644 --- a/apps/page/src/logic/sagas/index.ts +++ b/apps/page/src/logic/sagas/index.ts @@ -46,7 +46,8 @@ export async function createSideEffects() { selectSearchOverlayVisible: selectors.search.visible, selectSubscribeOverlayVisible: selectors.subscribeButton.visible, selectShowPoster: selectors.podcast.poster, - selectFeed: selectors.podcast.feed + selectFeed: selectors.podcast.feed, + selectThemeInitialized: selectors.theme.initialized }) ] as any[]; diff --git a/apps/page/src/logic/sagas/layout.sagas.ts b/apps/page/src/logic/sagas/layout.sagas.ts index 4083bdae..5a41e1c4 100644 --- a/apps/page/src/logic/sagas/layout.sagas.ts +++ b/apps/page/src/logic/sagas/layout.sagas.ts @@ -1,22 +1,27 @@ import type { EventChannel } from 'redux-saga'; import { call, fork, put, select, takeEvery } from 'redux-saga/effects'; import { channel } from '@podlove/player-sagas/helper'; -import { lighten } from 'farbraum'; +import { isDark, lighten } from 'farbraum'; +import type { Action } from 'redux-actions'; import actions from '../store/actions'; import { isClient } from '../../lib/runtime'; import type { ColorTokens, rgbColor } from '../../types/color.types'; import getImageColors from '../../lib/get-image-color'; +import type { initializeThemePayload } from '../store/stores/theme.store'; +import { isArray } from 'lodash-es'; export default function ({ selectSubscribeOverlayVisible, selectSearchOverlayVisible, - selectShowPoster + selectShowPoster, + selectThemeInitialized }: { selectSubscribeOverlayVisible: (input: any) => boolean; selectSearchOverlayVisible: (input: any) => boolean; selectShowPoster: (input: any) => string | null; selectFeed: (input: any) => string | null; + selectThemeInitialized: (input: any) => boolean; }) { function* disableOverflow() { document.body.classList.add('overflow-hidden'); @@ -34,7 +39,7 @@ export default function ({ yield put(actions.view.stopLoading()); } - function* initializeTheme() { + function* initializeTheme({ payload }: Action) { const poster: string | null = yield select(selectShowPoster); const tailwindColorTokens = (color: rgbColor | null): ColorTokens | null => { @@ -43,6 +48,7 @@ export default function ({ if (!color) { return null; } + return tokens.reduce( (result, token) => ({ ...result, @@ -52,13 +58,22 @@ export default function ({ ) as ColorTokens; }; - if (!poster) { + let primaryColor: rgbColor | null = null; + + if (isArray(payload.primaryColor)) { + primaryColor = payload.primaryColor; + } + + if (!primaryColor && poster) { + primaryColor = yield getImageColors(`/api/proxy?url=${poster}`); + } + + if (!primaryColor) { return; } - const { primaryColor, complementaryColor } = yield getImageColors(`/api/proxy?url=${poster}`); const primary = tailwindColorTokens(primaryColor); - const complementary = tailwindColorTokens(complementaryColor); + const complementary = tailwindColorTokens(isDark(primaryColor) ? [240, 240, 240] : [1, 1, 1]); yield put( actions.theme.setTheme({ @@ -71,18 +86,24 @@ export default function ({ } return function* () { - if (isClient()) { - yield fork(initializeTheme); + yield takeEvery(actions.theme.initializeTheme.toString(), initializeTheme); + if (isClient()) { const pageLoadStart: EventChannel = yield call(channel, (cb: EventListener) => document.addEventListener('astro:before-preparation', cb) ); const pageLoadEnd: EventChannel = yield call(channel, (cb: EventListener) => document.addEventListener('astro:after-preparation', cb) ); - yield takeEvery(pageLoadStart, startLoading); yield takeEvery(pageLoadEnd, stopLoading); + + const initialized: boolean = yield select(selectThemeInitialized); + + if (!initialized) { + yield put(actions.theme.initializeTheme({ primaryColor: null })); + } + } while (true) { diff --git a/apps/page/src/logic/store/stores/theme.store.ts b/apps/page/src/logic/store/stores/theme.store.ts index ae54e39c..4211fe42 100644 --- a/apps/page/src/logic/store/stores/theme.store.ts +++ b/apps/page/src/logic/store/stores/theme.store.ts @@ -1,5 +1,5 @@ import { handleActions, createAction, type Action } from 'redux-actions'; -import type { ColorTokens } from '../../../types/color.types'; +import type { ColorTokens, rgbColor } from '../../../types/color.types'; export interface Colors { primary: ColorTokens; @@ -11,8 +11,13 @@ export type setThemePayload = { colors: Partial; } +export type initializeThemePayload = { + primaryColor: rgbColor | null; +} + export const actions = { - setTheme: createAction('COLORS_SET') + setTheme: createAction('THEME_SET'), + initializeTheme: createAction('THEME_INIT') }; export interface State { diff --git a/apps/page/src/middleware/request-param.ts b/apps/page/src/middleware/request-param.ts index 2d3d0661..089d53f2 100644 --- a/apps/page/src/middleware/request-param.ts +++ b/apps/page/src/middleware/request-param.ts @@ -1,23 +1,18 @@ import { defineMiddleware } from 'astro:middleware'; import { get } from 'lodash-es'; import { extractDnsData } from '../lib/dns-record'; +import type { rgbColor } from '../types/color.types'; -export const getRequestParams = ( - request: Request -): { feed: string | undefined; episodeId: string | undefined; customDomain: boolean } => { - const { feed, episodeId, customDomain } = get(request, 'data') as unknown as { - feed: string; - episodeId: string; - customDomain: boolean; - }; - - return { - feed, - episodeId, - customDomain - }; +type RequestParams = { + feed: string | undefined; + episodeId: string | undefined; + customDomain: boolean; + primaryColor: rgbColor | undefined; }; +export const getRequestParams = (request: Request): RequestParams => + get(request, 'data') as unknown as RequestParams; + export const extractRequestParams = defineMiddleware(async (context, next) => { const { request, params } = context; const { feed, episodeId } = params; diff --git a/apps/page/src/middleware/store.ts b/apps/page/src/middleware/store.ts index 0bdfc458..f9890652 100644 --- a/apps/page/src/middleware/store.ts +++ b/apps/page/src/middleware/store.ts @@ -11,7 +11,7 @@ const version = import.meta.env.VITE_COMMIT_HASH; export const initializeStore = defineMiddleware(async ({ request }, next) => { const locale = getRequestHeader(request, 'accept-language', 'en-US'); - const { feed, episodeId, customDomain } = getRequestParams(request); + const { feed, episodeId, customDomain, primaryColor } = getRequestParams(request); if (!feed) { throw new Error('Missing Feed'); @@ -30,5 +30,10 @@ export const initializeStore = defineMiddleware(async ({ request }, next) => { const cacheKey: string | null = data.etag ? await createHash(`${data.etag}${version}`) : null; store.dispatch(actions.lifecycle.dataFetched({ data, cacheKey, version })); + + if (primaryColor) { + store.dispatch(actions.theme.initializeTheme({ primaryColor })); + } + return next(); });