Skip to content
This repository has been archived by the owner on Oct 13, 2024. It is now read-only.

Commit

Permalink
Merge pull request #359 from Swetrix/improvement/server-theme-detection
Browse files Browse the repository at this point in the history
(improvement) Server-side theme detection
  • Loading branch information
Blaumaus authored Sep 8, 2023
2 parents eaf3a34 + 5b7e1ea commit 939c3e7
Show file tree
Hide file tree
Showing 11 changed files with 93 additions and 47 deletions.
1 change: 1 addition & 0 deletions app/redux/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export const SWETRIX_VS_SIMPLE_ANALYTICS: string = 'https://blog.swetrix.com/pos

export const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'

export type ThemeType = 'dark' | 'light'
export const SUPPORTED_THEMES: string[] = ['light', 'dark']

export const CONTACT_EMAIL: string = '[email protected]'
Expand Down
20 changes: 12 additions & 8 deletions app/redux/reducers/ui/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import _includes from 'lodash/includes'
import { setCookie, getCookie } from 'utils/cookie'
import {
isBrowser, LS_THEME_SETTING, SUPPORTED_THEMES, THEME_TYPE,
isBrowser, LS_THEME_SETTING, SUPPORTED_THEMES, THEME_TYPE, ThemeType,
} from 'redux/constants'

const setThemeToDOM = (theme: string) => {
Expand All @@ -17,13 +17,17 @@ const setThemeToDOM = (theme: string) => {
root.classList.add(theme)
}

const setTheme = (theme: string): string => {
const setTheme = (theme: string, storeToCookie = true): string => {
setThemeToDOM(theme)
setCookie(LS_THEME_SETTING, theme)

if (storeToCookie) {
setCookie(LS_THEME_SETTING, theme)
}

return theme
}

const getInitialTheme = (): 'light' | 'dark' => {
const getInitialTheme = (): ThemeType => {
if (!isBrowser) {
return 'light'
}
Expand All @@ -41,13 +45,13 @@ const getInitialTheme = (): 'light' | 'dark' => {
// return 'dark'
// }

setTheme('light')
setTheme('light', false)
return 'light' // light theme as the default
}

interface IInitialState {
theme: 'light' | 'dark'
type: string
theme: ThemeType
type: string
}

const getInitialState = (): IInitialState => {
Expand All @@ -61,7 +65,7 @@ const themeSlice = createSlice({
name: 'theme',
initialState: getInitialState(),
reducers: {
setTheme(state, { payload }: PayloadAction<'light' | 'dark'>) {
setTheme(state, { payload }: PayloadAction<ThemeType>) {
setTheme(payload)
state.theme = payload
},
Expand Down
19 changes: 15 additions & 4 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
whitelist, isBrowser, CONTACT_EMAIL, LS_THEME_SETTING,
} from 'redux/constants'
import {
getCookie,
getCookie, generateCookieString,
} from 'utils/cookie'
import { ExclamationTriangleIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline'
import { Provider } from 'react-redux'
Expand All @@ -27,7 +27,6 @@ import _map from 'lodash/map'
// @ts-ignore
import { transitions, positions, Provider as AlertProvider } from '@blaumaus/react-alert'
import BillboardCss from 'billboard.js/dist/billboard.min.css'
// import { getCookie } from 'utils/cookie'

import AlertTemplate from 'ui/Alert'
import { trackViews } from 'utils/analytics'
Expand Down Expand Up @@ -84,6 +83,7 @@ export const meta: V2_MetaFunction = () => [
]

export const headers: HeadersFunction = () => ({
// General headers
'access-control-allow-origin': '*',
'Cross-Origin-Embedder-Policy': 'require-corp; report-to="default";',
'Cross-Origin-Opener-Policy': 'same-site; report-to="default";',
Expand All @@ -92,6 +92,10 @@ export const headers: HeadersFunction = () => ({
'Referrer-Policy': 'strict-origin-when-cross-origin',
'X-Powered-By': 'Mountain Dew',
'X-XSS-Protection': '1; mode=block',
// Theme detection headers (browser hints)
'Accept-CH': 'Sec-CH-Prefers-Color-Scheme',
Vary: 'Sec-CH-Prefers-Color-Scheme',
'Critical-CH': 'Sec-CH-Prefers-Color-Scheme',
})

export function ErrorBoundary() {
Expand Down Expand Up @@ -178,7 +182,7 @@ export function ErrorBoundary() {
export async function loader({ request }: LoaderArgs) {
const { url } = request
const locale = detectLanguage(request)
const theme = detectTheme(request)
const [theme, storeThemeToCookie] = detectTheme(request)
const isAuthed = isAuthenticated(request)

const REMIX_ENV = {
Expand All @@ -192,9 +196,16 @@ export async function loader({ request }: LoaderArgs) {
STAGING: process.env.STAGING,
}

const init = storeThemeToCookie ? {
headers: {
// 21600 seconds = 6 hours
'Set-Cookie': generateCookieString(LS_THEME_SETTING, theme, 21600),
},
} : undefined

return json({
locale, url, theme, REMIX_ENV, isAuthed,
})
}, init)
}

export const handle = {
Expand Down
2 changes: 1 addition & 1 deletion app/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export async function loader({ request }: LoaderArgs) {
return redirect('/login', 302)
}

const theme = detectTheme(request)
const [theme] = detectTheme(request)
const isAuth = isAuthenticated(request)

return json({ theme, isAuth })
Expand Down
2 changes: 1 addition & 1 deletion app/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const headers: HeadersFunction = ({ parentHeaders }) => {
}

export async function loader({ request }: LoaderArgs) {
const theme = detectTheme(request)
const [theme] = detectTheme(request)

return json({ theme })
}
Expand Down
2 changes: 1 addition & 1 deletion app/routes/projects.$id.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const meta: V2_MetaFunction = ({ location }) => {
}

export async function loader({ request }: LoaderArgs) {
const theme = detectTheme(request)
const [theme] = detectTheme(request)

return json({ theme })
}
Expand Down
2 changes: 1 addition & 1 deletion app/routes/signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export async function loader({ request }: LoaderArgs) {
return redirect('/login', 302)
}

const theme = detectTheme(request)
const [theme] = detectTheme(request)

return json({ theme })
}
Expand Down
6 changes: 5 additions & 1 deletion app/utils/cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,16 @@ export const getCookie = (key: string) => {
return null
}

export const generateCookieString = (key: string, value: string | number | boolean, maxAge = 3600, sameSite = 'strict') => {
return `${key}=${value}; max-age=${maxAge}; path=/; SameSite=${sameSite}${COOKIE_SUFFIX}`
}

export const setCookie = (key: string, value: string | number | boolean, maxAge = 3600, sameSite = 'strict') => {
if (!isBrowser) {
return null
}

document.cookie = `${key}=${value}; max-age=${maxAge}; path=/; SameSite=${sameSite}${COOKIE_SUFFIX}`
document.cookie = generateCookieString(key, value, maxAge, sameSite)
}

export const deleteCookie = (key: string) => {
Expand Down
29 changes: 23 additions & 6 deletions app/utils/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import routes from 'routesPath'
import { TITLE_SUFFIX } from 'redux/constants'
import _includes from 'lodash/includes'
import { TITLE_SUFFIX, SUPPORTED_THEMES, ThemeType } from 'redux/constants'

export const hasAuthCookies = (request: Request) => {
const cookie = request.headers.get('Cookie')
Expand All @@ -9,15 +10,31 @@ export const hasAuthCookies = (request: Request) => {
return accessToken && refreshToken
}

export function detectTheme(request: Request): 'dark' | 'light' {
/**
* Function detects theme based on user's browser hints and cookies
*
* @param request
* @returns [theme, storeToCookie]
*/
export function detectTheme(request: Request): [ThemeType, boolean] {
// Stage 1: Check if user has set theme manually
const cookie = request.headers.get('Cookie')
const theme = cookie?.match(/(?<=colour-theme=)[^;]*/)?.[0]
const theme = cookie?.match(/(?<=colour-theme=)[^;]*/)?.[0] as ThemeType

if (theme === 'dark') {
return 'dark'
if (_includes(SUPPORTED_THEMES, theme)) {
return [theme, false]
}

return 'light'
// Stage 2: Try to detect theme based on Sec-CH browser hints
// Currently only Chromium-based browsers support this feature
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-Prefers-Color-Scheme
const hintedTheme = request.headers.get('Sec-CH-Prefers-Color-Scheme') as ThemeType

if (_includes(SUPPORTED_THEMES, hintedTheme)) {
return [hintedTheme, true]
}

return ['light', false]
}

export function getAccessToken(request: Request): string | null {
Expand Down
47 changes: 24 additions & 23 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@
"@remix-run/node": "^1.19.3",
"@remix-run/react": "^1.19.3",
"@remix-run/serve": "^1.19.3",
"@remix-run/v1-route-convention": "^0.1.2",
"@swetrix/sdk": "^1.1.0",
"@tailwindcss/forms": "^0.5.4",
"@testing-library/jest-dom": "^5.17.0",
"axios": "^1.4.0",
"@tailwindcss/forms": "^0.5.6",
"@testing-library/jest-dom": "^6.1.3",
"axios": "^1.5.0",
"axios-auth-refresh": "^3.3.6",
"babel-jest": "^29.6.2",
"billboard.js": "^3.9.3",
"babel-jest": "^29.6.4",
"billboard.js": "^3.9.4",
"clsx": "^2.0.0",
"d3": "^7.8.5",
"dangerously-set-html-content": "^1.0.13",
Expand All @@ -42,10 +43,10 @@
"file-saver": "^2.0.5",
"flatpickr": "^4.6.13",
"i18n-iso-countries": "^7.6.0",
"i18next": "^23.4.4",
"i18next": "^23.4.9",
"i18next-browser-languagedetector": "^7.1.0",
"i18next-fs-backend": "^2.1.5",
"i18next-http-backend": "^2.2.1",
"i18next-http-backend": "^2.2.2",
"isbot": "^3.6.13",
"jszip": "^3.10.1",
"lodash": "^4.17.21",
Expand All @@ -54,7 +55,7 @@
"react-dom": "^18.2.0",
"react-flagkit": "^2.0.4",
"react-flatpickr": "^3.10.13",
"react-i18next": "^13.1.2",
"react-i18next": "^13.2.2",
"react-outside-click-handler": "^1.3.0",
"react-qr-code": "^2.0.12",
"react-redux": "^8.1.2",
Expand All @@ -64,9 +65,9 @@
"redux": "^4.2.1",
"redux-saga": "^1.2.3",
"remix-i18next": "^5.3.0",
"remix-sitemap": "^2.2.0",
"remix-sitemap": "^2.2.7",
"remix-utils": "^6.6.0",
"spacetime": "^7.4.6",
"spacetime": "^7.4.7",
"swetrix": "^2.3.0",
"timezone-soft": "^1.4.1",
"ts-jest": "^29.1.1"
Expand Down Expand Up @@ -183,33 +184,33 @@
"devDependencies": {
"@remix-run/dev": "^1.19.3",
"@remix-run/eslint-config": "^1.19.3",
"@tailwindcss/typography": "^0.5.9",
"@tailwindcss/typography": "^0.5.10",
"@testing-library/react": "^14.0.0",
"@types/debug": "^4.1.8",
"@types/jest": "^29.5.3",
"@types/lodash": "^4.14.197",
"@types/node": "^20.5.0",
"@types/jest": "^29.5.4",
"@types/lodash": "^4.14.198",
"@types/node": "^20.5.9",
"@types/prop-types": "^15.7.5",
"@types/react": "^18.2.20",
"@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7",
"@types/react-flatpickr": "^3.8.8",
"@types/react-test-renderer": "^18.0.0",
"@types/react-test-renderer": "^18.0.1",
"@types/redux-mock-store": "^1.0.3",
"@types/testing-library__jest-dom": "^5.14.9",
"@typescript-eslint/parser": "^6.4.0",
"@typescript-eslint/typescript-estree": "^6.4.0",
"@typescript-eslint/parser": "^6.6.0",
"@typescript-eslint/typescript-estree": "^6.6.0",
"autoprefixer": "^10.4.15",
"cross-env": "^7.0.3",
"eslint": "^8.47.0",
"eslint": "^8.48.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-react-hooks": "^4.6.0",
"jest": "^29.6.2",
"jest-environment-jsdom": "^29.6.2",
"postcss": "^8.4.28",
"jest": "^29.6.4",
"jest-environment-jsdom": "^29.6.4",
"postcss": "^8.4.29",
"resize-observer-polyfill": "^1.5.1",
"tailwindcss": "^3.3.3",
"typescript": "^5.1.6"
"typescript": "^5.2.2"
},
"engines": {
"node": ">=16"
Expand Down
10 changes: 9 additions & 1 deletion remix.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
const {
createRoutesFromFolders,
} = require("@remix-run/v1-route-convention")

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
ignoredRouteFiles: ['**/.*'],
Expand All @@ -14,7 +18,7 @@ module.exports = {
v2_normalizeFormMethod: true,
v2_dev: true,
v2_headers: true,
// v2_routeConvention: true,
v2_routeConvention: true,
},
serverDependenciesToBundle: [
'axios',
Expand All @@ -28,4 +32,8 @@ module.exports = {
'robust-predicates',
],
serverMinify: process.env.NODE_ENV === 'production',
routes(defineRoutes) {
// uses the v1 convention, works in v1.15+ and v2
return createRoutesFromFolders(defineRoutes)
}
}

0 comments on commit 939c3e7

Please sign in to comment.