Add 'system' theme option that follows OS prefers-color-scheme
This commit is contained in:
+6
-2
@@ -11,9 +11,13 @@
|
||||
var theme = 'dark';
|
||||
try {
|
||||
var cfg = JSON.parse(localStorage.getItem('nostr:app-config') || '{}');
|
||||
if (cfg.theme && themes[cfg.theme]) theme = cfg.theme;
|
||||
if (cfg.theme && (themes[cfg.theme] || cfg.theme === 'system')) theme = cfg.theme;
|
||||
} catch (e) {}
|
||||
var t = themes[theme];
|
||||
// Resolve "system" to light or dark based on OS preference
|
||||
if (theme === 'system') {
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
var t = themes[theme] || themes.dark;
|
||||
document.documentElement.className = theme;
|
||||
document.body.style.background = t.bg;
|
||||
var p = document.getElementById('preloader');
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ReactNode, useEffect } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata } from '@/contexts/AppContext';
|
||||
import { themes, buildThemeCss } from '@/themes';
|
||||
import { themes, buildThemeCss, resolveTheme } from '@/themes';
|
||||
import { ThemeSchema, FeedSettingsSchema, ContentWarningPolicySchema } from '@/lib/schemas';
|
||||
|
||||
interface AppProviderProps {
|
||||
@@ -101,21 +101,35 @@ export function AppProvider(props: AppProviderProps) {
|
||||
|
||||
/**
|
||||
* Hook to apply theme changes to the document root via an injected <style> tag.
|
||||
* When theme is "system", resolves to "light" or "dark" based on OS preference
|
||||
* and listens for changes to prefers-color-scheme.
|
||||
*/
|
||||
function useApplyTheme(theme: Theme) {
|
||||
useEffect(() => {
|
||||
const tokens = themes[theme] ?? themes.dark;
|
||||
const css = buildThemeCss(tokens);
|
||||
function apply() {
|
||||
const resolved = resolveTheme(theme);
|
||||
const tokens = themes[resolved] ?? themes.dark;
|
||||
const css = buildThemeCss(tokens);
|
||||
|
||||
let el = document.getElementById('theme-vars') as HTMLStyleElement | null;
|
||||
if (!el) {
|
||||
el = document.createElement('style');
|
||||
el.id = 'theme-vars';
|
||||
document.head.appendChild(el);
|
||||
let el = document.getElementById('theme-vars') as HTMLStyleElement | null;
|
||||
if (!el) {
|
||||
el = document.createElement('style');
|
||||
el.id = 'theme-vars';
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = css;
|
||||
// Now that CSS variables are set, the inline body background from
|
||||
// theme.js is no longer needed — bg-background will resolve correctly.
|
||||
document.body.removeAttribute('style');
|
||||
}
|
||||
|
||||
apply();
|
||||
|
||||
// When theme is "system", listen for OS color scheme changes
|
||||
if (theme === 'system') {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mq.addEventListener('change', apply);
|
||||
return () => mq.removeEventListener('change', apply);
|
||||
}
|
||||
el.textContent = css;
|
||||
// Now that CSS variables are set, the inline body background from
|
||||
// theme.js is no longer needed — bg-background will resolve correctly.
|
||||
document.body.removeAttribute('style');
|
||||
}, [theme]);
|
||||
}
|
||||
@@ -209,7 +209,8 @@ function SyncScreen({ phase }: { phase: SyncPhase }) {
|
||||
// Setup Questionnaire
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const THEMES: { value: Theme; label: string; description: string; preview: string }[] = [
|
||||
const THEMES: { value: Theme; label: string; description: string; preview: string; splitPreview?: boolean }[] = [
|
||||
{ value: 'system', label: 'System', description: 'Matches your device', preview: '', splitPreview: true },
|
||||
{ value: 'dark', label: 'Dark', description: 'Deep purple dark theme', preview: 'bg-[hsl(228,20%,10%)]' },
|
||||
{ value: 'light', label: 'Light', description: 'Clean and bright', preview: 'bg-white border border-border' },
|
||||
{ value: 'black', label: 'Black', description: 'True OLED black', preview: 'bg-black' },
|
||||
@@ -963,12 +964,19 @@ function ThemeStep({
|
||||
: 'ring-1 ring-border',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-14 h-14 rounded-full transition-transform duration-200 group-hover:scale-110',
|
||||
theme.preview,
|
||||
)}
|
||||
/>
|
||||
{theme.splitPreview ? (
|
||||
<div className="w-14 h-14 rounded-full overflow-hidden transition-transform duration-200 group-hover:scale-110 flex">
|
||||
<div className="w-1/2 h-full bg-white" />
|
||||
<div className="w-1/2 h-full bg-[hsl(228,20%,10%)]" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'w-14 h-14 rounded-full transition-transform duration-200 group-hover:scale-110',
|
||||
theme.preview,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-0.5 text-center">
|
||||
<p className="text-sm font-medium">{theme.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{theme.description}</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Home, Bell, Search, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText, User, Settings, Bookmark, UserPlus, LogOut, Check, Moon, Sun, Heart, ChevronDown, Plus } from 'lucide-react';
|
||||
import { Home, Bell, Search, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText, User, Settings, Bookmark, UserPlus, LogOut, Check, Moon, Sun, Heart, Monitor, ChevronDown, Plus } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ChestIcon } from '@/components/icons/ChestIcon';
|
||||
import { CardsIcon } from '@/components/icons/CardsIcon';
|
||||
@@ -151,6 +151,7 @@ export function LeftSidebar() {
|
||||
};
|
||||
|
||||
const themes: { value: Theme; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: 'system', label: 'System', icon: <Monitor className="size-4" /> },
|
||||
{ value: 'dark', label: 'Dark', icon: <Palette className="size-4" /> },
|
||||
{ value: 'light', label: 'Light', icon: <Sun className="size-4" /> },
|
||||
{ value: 'black', label: 'Black', icon: <Moon className="size-4" /> },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { User, Bookmark, Settings, LogOut, ChevronDown, ChevronUp, Sun, Moon, Heart, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText } from 'lucide-react';
|
||||
import { User, Bookmark, Settings, LogOut, ChevronDown, ChevronUp, Sun, Moon, Heart, Monitor, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -78,6 +78,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
}, [feedSettings]);
|
||||
|
||||
const themes: { value: Theme; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: 'system', label: 'System', icon: <Monitor className="size-5" /> },
|
||||
{ value: 'dark', label: 'Dark', icon: <Palette className="size-5" /> },
|
||||
{ value: 'light', label: 'Light', icon: <Sun className="size-5" /> },
|
||||
{ value: 'black', label: 'Black', icon: <Moon className="size-5" /> },
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ThemeOption {
|
||||
}
|
||||
|
||||
const themeOptions: ThemeOption[] = [
|
||||
{ id: 'system', label: 'System' },
|
||||
{ id: 'light', label: 'Light', tokens: themes.light },
|
||||
{ id: 'dark', label: 'Dark', tokens: themes.dark },
|
||||
{ id: 'black', label: 'Black', tokens: themes.black },
|
||||
@@ -30,6 +31,74 @@ export function ThemeSelector() {
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{themeOptions.map((option) => {
|
||||
if (option.id === 'system') {
|
||||
const isActive = theme === 'system';
|
||||
const lightTokens = themes.light;
|
||||
const darkTokens = themes.dark;
|
||||
|
||||
return (
|
||||
<button
|
||||
key="system"
|
||||
className={cn(
|
||||
'relative group rounded-xl border-2 p-1 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isActive
|
||||
? 'border-primary shadow-sm'
|
||||
: 'border-border hover:border-primary/40',
|
||||
)}
|
||||
onClick={() => setTheme('system')}
|
||||
>
|
||||
{/* Split preview: left light, right dark */}
|
||||
<div className="aspect-[4/3] rounded-lg overflow-hidden relative">
|
||||
{/* Light half */}
|
||||
<div
|
||||
className="absolute inset-0 w-1/2"
|
||||
style={{ backgroundColor: hsl(lightTokens.background) }}
|
||||
>
|
||||
<div className="h-2.5 w-full" style={{ backgroundColor: hsl(lightTokens.card) }} />
|
||||
<div className="p-1.5 space-y-1">
|
||||
<div className="h-1 w-3/4 rounded-full" style={{ backgroundColor: hsl(lightTokens.foreground), opacity: 0.6 }} />
|
||||
<div className="h-1 w-1/2 rounded-full" style={{ backgroundColor: hsl(lightTokens.mutedForeground), opacity: 0.4 }} />
|
||||
<div className="pt-0.5">
|
||||
<div className="h-2 w-8 rounded-sm" style={{ backgroundColor: hsl(lightTokens.primary) }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Dark half */}
|
||||
<div
|
||||
className="absolute inset-0 left-1/2"
|
||||
style={{ backgroundColor: hsl(darkTokens.background) }}
|
||||
>
|
||||
<div className="h-2.5 w-full" style={{ backgroundColor: hsl(darkTokens.card) }} />
|
||||
<div className="p-1.5 space-y-1">
|
||||
<div className="h-1 w-3/4 rounded-full" style={{ backgroundColor: hsl(darkTokens.foreground), opacity: 0.6 }} />
|
||||
<div className="h-1 w-1/2 rounded-full" style={{ backgroundColor: hsl(darkTokens.mutedForeground), opacity: 0.4 }} />
|
||||
<div className="pt-0.5">
|
||||
<div className="h-2 w-8 rounded-sm" style={{ backgroundColor: hsl(darkTokens.primary) }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active check mark */}
|
||||
{isActive && (
|
||||
<div className="absolute top-1 left-1 size-4 rounded-full flex items-center justify-center"
|
||||
style={{ backgroundColor: hsl(lightTokens.primary) }}
|
||||
>
|
||||
<Check className="size-2.5" style={{ color: hsl(lightTokens.primaryForeground) }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<p className={cn(
|
||||
'mt-1.5 text-xs font-medium text-center transition-colors',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}>
|
||||
{option.label}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (option.id === 'custom') {
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export type Theme = "dark" | "light" | "black" | "pink";
|
||||
/** A concrete theme with its own color palette. */
|
||||
export type ConcreteTheme = "dark" | "light" | "black" | "pink";
|
||||
|
||||
/** User-facing theme preference. "system" resolves to "light" or "dark" based on OS preference. */
|
||||
export type Theme = ConcreteTheme | "system";
|
||||
|
||||
/**
|
||||
* How to handle events with a NIP-36 content-warning tag.
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import type { Theme, ContentWarningPolicy } from '@/contexts/AppContext';
|
||||
|
||||
/** Zod schema for Theme validation */
|
||||
export const ThemeSchema = z.enum(['dark', 'light', 'black', 'pink']) satisfies z.ZodType<Theme>;
|
||||
export const ThemeSchema = z.enum(['dark', 'light', 'black', 'pink', 'system']) satisfies z.ZodType<Theme>;
|
||||
|
||||
/** Zod schema for ContentWarningPolicy validation */
|
||||
export const ContentWarningPolicySchema = z.enum(['blur', 'hide', 'show']) satisfies z.ZodType<ContentWarningPolicy>;
|
||||
|
||||
+10
-2
@@ -1,4 +1,4 @@
|
||||
import type { Theme } from '@/contexts/AppContext';
|
||||
import type { ConcreteTheme, Theme } from '@/contexts/AppContext';
|
||||
|
||||
export interface ThemeTokens {
|
||||
background: string;
|
||||
@@ -30,7 +30,7 @@ export interface ThemeTokens {
|
||||
sidebarRing: string;
|
||||
}
|
||||
|
||||
export const themes: Record<Theme, ThemeTokens> = {
|
||||
export const themes: Record<ConcreteTheme, ThemeTokens> = {
|
||||
light: {
|
||||
background: '0 0% 100%',
|
||||
foreground: '222.2 84% 4.9%',
|
||||
@@ -164,3 +164,11 @@ export function buildThemeCss(tokens: ThemeTokens): string {
|
||||
.join(' ');
|
||||
return `:root { ${vars} }`;
|
||||
}
|
||||
|
||||
/** Resolves a theme preference to a concrete theme. "system" maps to "light" or "dark" based on OS preference. */
|
||||
export function resolveTheme(theme: Theme): ConcreteTheme {
|
||||
if (theme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return theme;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user