Skip to content

Commit

Permalink
feat(page): use primaryColor
Browse files Browse the repository at this point in the history
  • Loading branch information
alexander-heimbuch committed Jan 12, 2025
1 parent 9489d6c commit 4d4d64f
Show file tree
Hide file tree
Showing 9 changed files with 115 additions and 73 deletions.
2 changes: 1 addition & 1 deletion apps/page/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"quantize": "1.0.2",
"@heroicons/vue": "2.2.0",
"sanitize-html": "2.14.0",
"extract-domain": "5.0.2"
"tldts": "6.1.71"
},
"devDependencies": {
"@types/lodash-es": "4.17.12",
Expand Down
49 changes: 32 additions & 17 deletions apps/page/src/lib/dns-record.ts
Original file line number Diff line number Diff line change
@@ -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 { getDomain } from 'tldts';
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<string[]> =>
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<DnsAnswer[]> =>
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'], {
Expand All @@ -24,18 +27,30 @@ const getStore = (context: APIContext): KVNamespace =>
} as unknown as KVNamespace);

export const extractDnsData = async (context: APIContext): Promise<FeedData> => {
const domain = extractDomain(context.url.hostname);
const domain = getDomain(context.url.hostname);
const entryName = `lux.${domain}`;
const store = getStore(context);
const fallback = { feed: null, primary_color: null };

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) {
Expand Down
26 changes: 11 additions & 15 deletions apps/page/src/lib/get-image-color.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBufferLike>;
dimensions: { width: number; height: number };
}> =>
new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'Anonymous';
Expand All @@ -30,7 +32,7 @@ const parseImage = ({
data,
dimensions
}: {
data: ArrayBuffer;
data: Uint8ClampedArray<ArrayBufferLike>;
dimensions: { width: number; height: number };
}): ndarray.NdArray =>
ndarray(
Expand All @@ -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<rgbColor | null> =>
fetchImage(src)
.then(parseImage)
.then(convertToPixels)
.then(extractColors)
.catch(() => null);

.catch(() => ({ primaryColor: null, complementaryColor: null }));

export default getImageColors;
export default getImagePrimaryColor;
3 changes: 2 additions & 1 deletion apps/page/src/logic/sagas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

Expand Down
39 changes: 30 additions & 9 deletions apps/page/src/logic/sagas/layout.sagas.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -34,7 +39,7 @@ export default function ({
yield put(actions.view.stopLoading());
}

function* initializeTheme() {
function* initializeTheme({ payload }: Action<initializeThemePayload>) {
const poster: string | null = yield select(selectShowPoster);

const tailwindColorTokens = (color: rgbColor | null): ColorTokens | null => {
Expand All @@ -43,6 +48,7 @@ export default function ({
if (!color) {
return null;
}

return tokens.reduce(
(result, token) => ({
...result,
Expand All @@ -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({
Expand All @@ -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<KeyboardEvent> = yield call(channel, (cb: EventListener) =>
document.addEventListener('astro:before-preparation', cb)
);
const pageLoadEnd: EventChannel<KeyboardEvent> = 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) {
Expand Down
9 changes: 7 additions & 2 deletions apps/page/src/logic/store/stores/theme.store.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,8 +11,13 @@ export type setThemePayload = {
colors: Partial<Colors>;
}

export type initializeThemePayload = {
primaryColor: rgbColor | null;
}

export const actions = {
setTheme: createAction<setThemePayload>('COLORS_SET')
setTheme: createAction<setThemePayload>('THEME_SET'),
initializeTheme: createAction<initializeThemePayload>('THEME_INIT')
};

export interface State {
Expand Down
27 changes: 11 additions & 16 deletions apps/page/src/middleware/request-param.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,28 @@
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;

const dns = await extractDnsData(context);

(request as any).data = {
feed: get(dns, 'feed', feed),
feed: dns.feed || feed,
episodeId,
customDomain: !!dns,
customDomain: !!dns.feed,
primaryColor: get(dns, 'primary_color', null)
};

Expand Down
7 changes: 6 additions & 1 deletion apps/page/src/middleware/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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();
});
Loading

0 comments on commit 4d4d64f

Please sign in to comment.