From e424db075bd657719b09b4f46bbbc01a64168666 Mon Sep 17 00:00:00 2001 From: Peter Hozak Date: Sat, 2 Sep 2023 15:00:20 +0100 Subject: [PATCH] support theme=light/dark in url for embedding on other sites #307 #229 --- app/assets/icons/tag.svg | 2 +- app/components/icons-generated/Tag.js | 22 ++--- app/hooks/theme.tsx | 54 ++++++++++++ app/root.css | 121 ++++++++++++++++++-------- app/root.tsx | 8 +- package.json | 1 + 6 files changed, 161 insertions(+), 47 deletions(-) create mode 100644 app/hooks/theme.tsx diff --git a/app/assets/icons/tag.svg b/app/assets/icons/tag.svg index b356f92d..46c0db0f 100644 --- a/app/assets/icons/tag.svg +++ b/app/assets/icons/tag.svg @@ -6,7 +6,7 @@ preserveAspectRatio="xMidYMid meet"> +fill="#000000" stroke="none" class="gray"> - - - - + + + + + + ) export default SvgTag diff --git a/app/hooks/theme.tsx b/app/hooks/theme.tsx new file mode 100644 index 00000000..1fc31ea1 --- /dev/null +++ b/app/hooks/theme.tsx @@ -0,0 +1,54 @@ +import {useCallback, useEffect, useState} from 'react' +import {useSearchParams} from '@remix-run/react' + +const THEME = 'theme' +const supported = new Set(['dark', 'light', undefined]) +const isSupported = (theme: string | null | undefined): theme is string | undefined => + theme !== null && supported.has(theme) + +export const useTheme = () => { + const [searchParams] = useSearchParams() // url parameter for iframe usage: ...?embedded&theme=dark + const [savedTheme, setSavedTheme] = useState(() => { + const fromParams = searchParams.get(THEME) + if (isSupported(fromParams)) return fromParams + + if (typeof window === 'undefined') return + const fromStorage = window.localStorage.getItem(THEME) + if (isSupported(fromStorage)) return fromStorage + }) + + // remember UI theme toggle state in localStorage + const setStorageTheme = useCallback((theme: string | undefined) => { + if (isSupported(theme)) { + if (!theme) { + window.localStorage.removeItem(THEME) + } else { + window.localStorage.setItem(THEME, theme) + } + setSavedTheme(theme) + } + }, []) + + // if no preference is saved, use the system preferences + useEffect(() => { + if (savedTheme) return + + const media = window.matchMedia('(prefers-color-scheme: dark)') + const updateHtmlClass = () => { + const classList = document.documentElement.classList + if (media.matches) { + classList.remove('light') + classList.add('dark') + } else { + classList.remove('dark') + classList.add('light') + } + } + media.addEventListener('change', updateHtmlClass) + updateHtmlClass() + + return () => media.removeEventListener('change', updateHtmlClass) + }, [savedTheme]) + + return {savedTheme, setStorageTheme} +} diff --git a/app/root.css b/app/root.css index 9496346c..f16f7f14 100644 --- a/app/root.css +++ b/app/root.css @@ -4,8 +4,9 @@ box-sizing: border-box; } -:root { +:root, :root.light { /* light theme: */ + --bgColorPage: #eee; --bgColorQuestionTitle: #bdf; --bgColorQuestionAnswer: #eff; --bgColorCopied: #fed; @@ -14,6 +15,9 @@ --colorTitleHighlight: #d86; --colorQuestionTitle: #333; --borderColor: #ddd; + --borderColorButton: #999; + --bgColorInput: #fff; + --bgColorButton: #ddd; --bgColorHighlight: #c0d3e4; --bgColorTableRows: #e8f7fe; --colorLink: #79f; @@ -28,17 +32,6 @@ /* dark theme at the end of file to overwrite all previous rules */ } -.loader { - flex: none; - border: 4px solid #f3f3f3; /* Light grey */ - border-top: 4px solid #3498db; /* Blue */ - border-radius: 50%; - width: 30px; - height: 30px; - animation: spin 2s linear infinite; - margin: 10px auto; -} - @keyframes spin { 0% { transform: rotate(0deg); @@ -67,6 +60,12 @@ html { height: 100%; overflow-y: scroll; + background: var(--bgColorPage); +} + +html.embed { + background: transparent; + padding: var(--paddingSides) 0; } body { @@ -104,6 +103,33 @@ a:hover, cursor: pointer; } +/* reset default styles if user prefers light/dark mode, but chooses dark/light mode in the toggle */ +input, textarea, select { + background: var(--bgColorInput); + color: var(--colorText); + border: 1px solid var(--borderColorButton); + border-radius: 4px; +} + +button { + background: var(--bgColorButton); + color: var(--colorText); + border: 1px solid var(--borderColorButton); + border-radius: 4px; + cursor: pointer; +} + +.loader { + flex: none; + border: 4px solid #f3f3f3; /* Light grey */ + border-top: 4px solid #3498db; /* Blue */ + border-radius: 50%; + width: 30px; + height: 30px; + animation: spin 2s linear infinite; + margin: 10px auto; +} + header { display: flex; align-items: flex-start; @@ -635,6 +661,8 @@ footer > *:not(:last-child) { .dialog { width: 30em; + background: var(--bgColorPage); + color: var(--colorText); } /* Make sure older browsers work correctly */ div.dialog { @@ -791,31 +819,56 @@ a[target='_blank']:not(.icon-link):after { /* dark theme */ @media (prefers-color-scheme: dark) { :root { - --bgColorQuestionTitle: #30386e; - --bgColorQuestionAnswer: #171f29; - --bgColorCopied: #fed; - --colorText: #f7fff9; - --colorTitle: #f7fff9; - --colorTitleHighlight: #d86; - --colorQuestionTitle: #ccc; - --borderColor: #444; - --bgColorHighlight: #354652; - --bgColorTableRows: #1d2436; - --colorLink: #89e3ff; - --colorLinkVisited: #79f; - --colorTooltip: #2e2e2e; + /* duplicate to avoid flash of white background while JS loads, consider some CSS postprocessing solution if this needs to be maintainable */ + --bgColorPage: #333; + --bgColorQuestionTitle: #30386e; + --bgColorQuestionAnswer: #171f29; + --bgColorCopied: #fed; + --colorText: #f7fff9; + --colorTitle: #f7fff9; + --colorTitleHighlight: #d86; + --colorQuestionTitle: #ccc; + --borderColor: #444; + --borderColorButton: #666; + --bgColorInput: #2b2b2b; + --bgColorButton: #222; + --bgColorHighlight: #354652; + --bgColorTableRows: #1d2436; + --colorLink: #89e3ff; + --colorLinkVisited: #79f; + --colorTooltip: #2e2e2e; } +} +:root.dark { + --bgColorPage: #333; + --bgColorQuestionTitle: #30386e; + --bgColorQuestionAnswer: #171f29; + --bgColorCopied: #fed; + --colorText: #f7fff9; + --colorTitle: #f7fff9; + --colorTitleHighlight: #d86; + --colorQuestionTitle: #ccc; + --borderColor: #444; + --borderColorButton: #666; + --bgColorInput: #2b2b2b; + --bgColorButton: #222; + --bgColorHighlight: #354652; + --bgColorTableRows: #1d2436; + --colorLink: #89e3ff; + --colorLinkVisited: #79f; + --colorTooltip: #2e2e2e; +} - svg *[class$='svg__gray'] { - fill: #ccc; - } +:root.dark svg *[class$='svg__gray'] { + fill: #ccc; +} - article > h2::after { - filter: invert(1); - } +:root.dark article > h2::after { + filter: invert(1); +} - .icon-link:active { - filter: drop-shadow(0 4px 8px rgba(255, 255, 255, 0.2)); - } +:root.dark .icon-link:active { + filter: drop-shadow(0 4px 8px rgba(255, 255, 255, 0.2)); } + /* do not add more rules below, add them above dark theme */ diff --git a/app/root.tsx b/app/root.tsx index 5cab22f2..c70dd8ca 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -5,6 +5,7 @@ import styles from '~/root.css' import {useLoaderData} from '@remix-run/react' import {questionsOnPage} from '~/hooks/stateModifiers' import {loadQuestionDetail} from '~/server-utils/stampy' +import {useTheme} from './hooks/theme' /* * Transform the given text into a meta header format. @@ -106,7 +107,9 @@ function Head({minLogo}: {minLogo?: boolean}) { ) } -export function ErrorBoundary() { +export function ErrorBoundary({error}: {error: Error}) { + console.error(error) + return ( @@ -123,9 +126,10 @@ export function ErrorBoundary() { export default function App() { const {minLogo} = useLoaderData>() + const {savedTheme} = useTheme() return ( - + diff --git a/package.json b/package.json index 73e2b049..68f3a4d5 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "dev": "npm start", "prod": "cross-env NODE_ENV=production miniflare ./build/index.js", "tsc": "tsc", + "wrangler": "wrangler", "deploy": "npm run build && wrangler publish", "test": "jest" },