Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

# testing
/coverage
/test-results/
/playwright-report/
/playwright/.cache/

# next.js
/.next/
Expand Down
35 changes: 24 additions & 11 deletions app/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import Link from "next/link";
import { useEffect, useState, useMemo } from 'react';
import { usePathname } from 'next/navigation';
import { SettingsModal } from './ui/SettingsModal';

export default function Footer() {
const [showFooter, setShowFooter] = useState(true);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const pathname = usePathname();

// Define paths where we should hide the footer
Expand All @@ -24,17 +26,28 @@ export default function Footer() {
if (!showFooter) return null;

return (
<footer className="text-center py-6 text-gray-500 dark:text-gray-400">
<div className="border-t border-gray-300 dark:border-gray-700 w-4/5 mx-auto mt-5 mb-2.5 pt-5">
<div className="flex gap-4 justify-center">
<p><Link href="/support" className="hover:underline">Support</Link></p>
{!hideSourceOnPaths.some(path => pathname?.startsWith(path)) && (
<p><Link href="https://github.com/paviro/Relationship-Menu" className="hover:underline">Source Code</Link></p>
)}
<p><Link href="/privacy-policy" className="hover:underline">Privacy Policy</Link></p>
<p><Link href="/legal-disclosure" className="hover:underline">Legal Disclosure</Link></p>
<>
<footer className="text-center py-6 text-gray-500 dark:text-gray-400">
<div className="border-t border-gray-300 dark:border-gray-700 w-4/5 mx-auto mt-5 mb-2.5 pt-5">
<div className="flex gap-4 justify-center flex-wrap">
<p>
<button
onClick={() => setIsSettingsOpen(true)}
className="hover:underline focus:outline-none focus:ring-2 focus:ring-[var(--main-text-color)] rounded"
>
Settings
</button>
</p>
<p><Link href="/support" className="hover:underline">Support</Link></p>
{!hideSourceOnPaths.some(path => pathname?.startsWith(path)) && (
<p><Link href="https://github.com/paviro/Relationship-Menu" className="hover:underline">Source Code</Link></p>
)}
<p><Link href="/privacy-policy" className="hover:underline">Privacy Policy</Link></p>
<p><Link href="/legal-disclosure" className="hover:underline">Legal Disclosure</Link></p>
</div>
</div>
</div>
</footer>
</footer>
<SettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} />
</>
);
}
167 changes: 167 additions & 0 deletions app/components/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
'use client';

import { useEffect, useCallback, createContext, useContext, useState, ReactNode } from 'react';
import { ThemePreferences } from '../types';
import {
getThemePreferences,
saveThemePreferences,
resolveColorMode,
systemPrefersHighContrast,
DEFAULT_THEME,
THEME_STORAGE_KEY,
} from '../utils/themeStorage';

type ThemeContextValue = {
preferences: ThemePreferences;
setPreferences: (prefs: ThemePreferences) => void;
resolvedColorMode: 'light' | 'dark';
};

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

/**
* Checks if user has explicitly saved a contrast preference in localStorage.
* Returns false if localStorage is empty, corrupted, or missing the contrast field.
*/
function hasExplicitContrastPreference(): boolean {
if (typeof window === 'undefined') return false;

try {
const rawStored = localStorage.getItem(THEME_STORAGE_KEY);
if (!rawStored) return false;
const parsed = JSON.parse(rawStored);
return parsed.contrast !== undefined;
} catch {
return false;
}
}

/**
* Applies theme settings to the <html> element via data attributes
*/
function applyThemeToDocument(preferences: ThemePreferences) {
if (typeof document === 'undefined') return;

const root = document.documentElement;
const resolvedMode = resolveColorMode(preferences);

// Set data attributes that CSS will use to apply theme styles
root.setAttribute('data-color-mode', resolvedMode);
root.setAttribute('data-vision', preferences.vision);
root.setAttribute('data-contrast', preferences.contrast);
}

interface ThemeProviderProps {
children: ReactNode;
}

export default function ThemeProvider({ children }: ThemeProviderProps) {
const [preferences, setPreferencesState] = useState<ThemePreferences>(DEFAULT_THEME);
const [resolvedColorMode, setResolvedColorMode] = useState<'light' | 'dark'>('light');
const [mounted, setMounted] = useState(false);

// Updates preferences and persists to storage
const setPreferences = useCallback((newPrefs: ThemePreferences) => {
setPreferencesState(newPrefs);
saveThemePreferences(newPrefs);
applyThemeToDocument(newPrefs);
setResolvedColorMode(resolveColorMode(newPrefs));
}, []);

// Initialize: read stored preferences on mount
// Also respect system prefers-contrast if user hasn't explicitly set contrast
useEffect(() => {
const stored = getThemePreferences();

// If no explicit contrast preference, respect system prefers-contrast
let finalPrefs = stored;
if (!hasExplicitContrastPreference() && systemPrefersHighContrast()) {
finalPrefs = { ...stored, contrast: 'high' };
}

setPreferencesState(finalPrefs);
applyThemeToDocument(finalPrefs);
setResolvedColorMode(resolveColorMode(finalPrefs));
setMounted(true);
}, []);

// Listen for system color scheme changes
useEffect(() => {
if (!mounted) return;

const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

const handleChange = () => {
// Only react when in "system" mode
if (preferences.colorMode === 'system') {
applyThemeToDocument(preferences);
setResolvedColorMode(resolveColorMode(preferences));
}
};

mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [mounted, preferences]);

// Listen for system prefers-contrast changes
// Check both 'more' (CSS Level 5) and 'high' (earlier drafts) for browser compatibility
useEffect(() => {
if (!mounted) return;

const contrastQueryMore = window.matchMedia('(prefers-contrast: more)');
const contrastQueryHigh = window.matchMedia('(prefers-contrast: high)');

const handleContrastChange = () => {
// Only auto-update if user hasn't explicitly set contrast
if (!hasExplicitContrastPreference()) {
// Use systemPrefersHighContrast for consistent detection logic
const newContrast = systemPrefersHighContrast() ? 'high' : 'normal';
const newPrefs = { ...preferences, contrast: newContrast };
setPreferencesState(newPrefs);
applyThemeToDocument(newPrefs);
}
};

contrastQueryMore.addEventListener('change', handleContrastChange);
contrastQueryHigh.addEventListener('change', handleContrastChange);
return () => {
contrastQueryMore.removeEventListener('change', handleContrastChange);
contrastQueryHigh.removeEventListener('change', handleContrastChange);
};
}, [mounted, preferences]);

// Listen for theme change events triggered elsewhere
useEffect(() => {
const handleExternalChange = (event: Event) => {
const customEvent = event as CustomEvent<ThemePreferences>;
if (customEvent.detail) {
setPreferencesState(customEvent.detail);
applyThemeToDocument(customEvent.detail);
setResolvedColorMode(resolveColorMode(customEvent.detail));
}
};

window.addEventListener('themePreferencesChanged', handleExternalChange);
return () => window.removeEventListener('themePreferencesChanged', handleExternalChange);
}, []);

const contextValue: ThemeContextValue = {
preferences,
setPreferences,
resolvedColorMode,
};

return (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
}
Loading