diff --git a/index.html b/index.html index 2b553d9..1cbed43 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,23 @@ + diff --git a/src/app/App.tsx b/src/app/App.tsx index 1570291..eb5ff57 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -2,6 +2,7 @@ import {Redirect, Route, Switch} from 'wouter'; import {AppLayout, HackweekLoader, PageState} from './components/AppLayout'; import {GoogleIcon} from './components/GoogleIcon'; +import {ThemeToggle} from './components/ThemeToggle'; import {AdminAnalyticsPage} from './routes/AdminAnalyticsPage'; import {AdminPage} from './routes/AdminPage'; import {EditProjectPage, NewProjectPage} from './routes/ProjectEditorPage'; @@ -116,6 +117,7 @@ function AuthState({ }) { return (
+
+ {user.actualRole === 'admin' && (
viewing as {user.role === 'admin' ? 'admin' : 'user'} diff --git a/src/app/components/ThemeToggle.tsx b/src/app/components/ThemeToggle.tsx new file mode 100644 index 0000000..f8344f1 --- /dev/null +++ b/src/app/components/ThemeToggle.tsx @@ -0,0 +1,60 @@ +import {useEffect, useState} from 'react'; + +import { + applyTheme, + getStoredTheme, + getSystemTheme, + storeTheme, + type Theme, +} from '../theme'; + +export function ThemeToggle({className = ''}: {className?: string}) { + const [theme, setTheme] = useState(() => getStoredTheme() ?? getSystemTheme()); + + useEffect(() => { + const storedTheme = getStoredTheme(); + const activeTheme = storedTheme ?? getSystemTheme(); + setTheme(activeTheme); + applyTheme(activeTheme); + + if (storedTheme || !window.matchMedia) return; + + const media = window.matchMedia('(prefers-color-scheme: dark)'); + const syncWithSystem = (event: MediaQueryListEvent) => { + if (getStoredTheme()) return; + const nextTheme = event.matches ? 'dark' : 'light'; + setTheme(nextTheme); + applyTheme(nextTheme); + }; + media.addEventListener('change', syncWithSystem); + return () => media.removeEventListener('change', syncWithSystem); + }, []); + + function toggleTheme() { + const nextTheme = theme === 'dark' ? 'light' : 'dark'; + setTheme(nextTheme); + applyTheme(nextTheme); + storeTheme(nextTheme); + } + + const nextTheme = theme === 'dark' ? 'light' : 'dark'; + + return ( + + ); +} diff --git a/src/app/styles.css b/src/app/styles.css index 0d8e635..4ce5876 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -53,50 +53,48 @@ color-scheme: light; } -@media (prefers-color-scheme: dark) { - :root { - color-scheme: dark; - --ink: #f3eef8; - --muted: #b3a7bf; - --paper: #130f1c; - --soft: #1a1524; - --lavender: #241c33; - --line: #3a314a; - --border-strong: #4a415c; - --surface-raised: #1c1728; - --surface-tint: #221b31; - --surface-tint-strong: #2a2140; - --surface-tint-hover: #32274b; - --header-bg: rgba(19, 15, 28, 0.92); - --header-border: rgba(58, 49, 74, 0.9); - --on-accent: #fff; - --shadow-ink: 0, 0, 0; - --blurple: #9b82ff; - --dark-blurple: #6b45c8; - --pink: #ff8acc; - --green: #a8e84a; - --yellow: #ffc94a; - --danger: #ff6b7d; - --success-ink: #d4f59a; - --success-border: #5f8a2a; - --success-bg: #1f2e12; - --warning-ink: #ffd88a; - --warning-ink-strong: #ffe3a8; - --warning-border: #8a6a1e; - --warning-bg: #2f2612; - --danger-ink: #ffb3bc; - --danger-border: #8a3a46; - --danger-bg: #2f151a; - --pink-ink: #ffb3db; - --pink-border: #8a3a66; - --pink-bg: #2f1524; - --pink-bg-strong: #3d1c30; - --violet-border: #5b4a8a; - --violet-border-strong: #6a58a0; - --violet-border-hot: #7d6bb8; - --violet-bg: #2d2348; - --neutral-border: #5a5168; - } +:root[data-theme='dark'] { + color-scheme: dark; + --ink: #f3eef8; + --muted: #b3a7bf; + --paper: #130f1c; + --soft: #1a1524; + --lavender: #241c33; + --line: #3a314a; + --border-strong: #4a415c; + --surface-raised: #1c1728; + --surface-tint: #221b31; + --surface-tint-strong: #2a2140; + --surface-tint-hover: #32274b; + --header-bg: rgba(19, 15, 28, 0.92); + --header-border: rgba(58, 49, 74, 0.9); + --on-accent: #fff; + --shadow-ink: 0, 0, 0; + --blurple: #9b82ff; + --dark-blurple: #6b45c8; + --pink: #ff8acc; + --green: #a8e84a; + --yellow: #ffc94a; + --danger: #ff6b7d; + --success-ink: #d4f59a; + --success-border: #5f8a2a; + --success-bg: #1f2e12; + --warning-ink: #ffd88a; + --warning-ink-strong: #ffe3a8; + --warning-border: #8a6a1e; + --warning-bg: #2f2612; + --danger-ink: #ffb3bc; + --danger-border: #8a3a46; + --danger-bg: #2f151a; + --pink-ink: #ffb3db; + --pink-border: #8a3a66; + --pink-bg: #2f1524; + --pink-bg-strong: #3d1c30; + --violet-border: #5b4a8a; + --violet-border-strong: #6a58a0; + --violet-border-hot: #7d6bb8; + --violet-bg: #2d2348; + --neutral-border: #5a5168; } * { @@ -282,6 +280,55 @@ input:disabled { .textButton:hover { color: var(--blurple); } +.themeToggle { + position: relative; + display: grid; + width: 3rem; + height: 1.65rem; + flex: 0 0 3rem; + grid-template-columns: 1fr 1fr; + align-items: center; + padding: 0 0.22rem; + color: var(--muted); + border: 1px solid var(--line); + border-radius: 999px; + background: var(--soft); + transition: + border-color 120ms ease, + background 120ms ease; +} +.themeToggle:hover { + color: var(--ink); + border-color: var(--blurple); +} +.themeToggleSun, +.themeToggleMoon { + position: relative; + z-index: 1; + font-size: 0.7rem; + line-height: 1; + text-align: center; +} +.themeToggleThumb { + position: absolute; + top: 0.17rem; + left: 0.17rem; + width: 1.18rem; + height: 1.18rem; + border-radius: 50%; + background: var(--surface-raised); + box-shadow: 0 1px 3px rgba(var(--shadow-ink), 0.24); + transition: transform 160ms ease; +} +:root[data-theme='dark'] .themeToggleThumb { + transform: translateX(1.41rem); +} +.authThemeToggle { + position: fixed; + top: 1.25rem; + right: 1.25rem; + z-index: 1; +} .identityCopy, .identityCopy > span, .identityCopy > small { @@ -2727,10 +2774,6 @@ main { } .authShell--loading { overflow: hidden; - background: - radial-gradient(circle at 20% 15%, rgba(255, 112, 188, 0.14), transparent 28rem), - radial-gradient(circle at 82% 78%, rgba(117, 83, 255, 0.14), transparent 32rem), - var(--soft); } .pageState, .authState { @@ -2767,27 +2810,16 @@ main { line-height: 1.7; } .authState--loading { + overflow: hidden; + border-color: transparent; + background: transparent; + box-shadow: none; animation: loadingStateIn 400ms ease-out both; } .pageState--loading { + background: transparent; animation: loadingStateIn 400ms ease-out 200ms both; } -.authState--loading { - overflow: hidden; - border-color: rgba(117, 83, 255, 0.18); - box-shadow: 0 24px 70px rgba(78, 42, 154, 0.1); -} -.authState--loading::before { - position: absolute; - top: -8rem; - left: 50%; - width: 24rem; - height: 14rem; - content: ''; - background: radial-gradient(circle, rgba(117, 83, 255, 0.1), transparent 70%); - transform: translateX(-50%); - pointer-events: none; -} .hackweekLoader { position: relative; width: min(13rem, 72vw); diff --git a/src/app/theme.ts b/src/app/theme.ts new file mode 100644 index 0000000..3eeace3 --- /dev/null +++ b/src/app/theme.ts @@ -0,0 +1,28 @@ +export type Theme = 'light' | 'dark'; + +const THEME_STORAGE_KEY = 'hackweek.theme'; + +export function getStoredTheme(): Theme | null { + try { + const theme = window.localStorage.getItem(THEME_STORAGE_KEY); + return theme === 'light' || theme === 'dark' ? theme : null; + } catch { + return null; + } +} + +export function getSystemTheme(): Theme { + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +export function applyTheme(theme: Theme) { + document.documentElement.dataset.theme = theme; +} + +export function storeTheme(theme: Theme) { + try { + window.localStorage.setItem(THEME_STORAGE_KEY, theme); + } catch { + return; + } +} diff --git a/test/app/ThemeToggle.test.tsx b/test/app/ThemeToggle.test.tsx new file mode 100644 index 0000000..559f8e5 --- /dev/null +++ b/test/app/ThemeToggle.test.tsx @@ -0,0 +1,23 @@ +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {afterEach, describe, expect, it} from 'vitest'; + +import {ThemeToggle} from '../../src/app/components/ThemeToggle'; + +afterEach(() => { + window.localStorage.removeItem('hackweek.theme'); + delete document.documentElement.dataset.theme; +}); + +describe('theme toggle', () => { + it('switches modes and remembers the selected theme', async () => { + render(); + + const toggle = screen.getByRole('button', {name: 'Switch to dark mode'}); + await userEvent.click(toggle); + + expect(document.documentElement.dataset.theme).toBe('dark'); + expect(window.localStorage.getItem('hackweek.theme')).toBe('dark'); + expect(toggle.getAttribute('aria-label')).toBe('Switch to light mode'); + }); +});