Files
Portfolioo/src/components/ControlsDropdown.tsx
T
ssbg04 b7723b11d9 feat: redesign portfolio to bryl-minimal aesthetic and refine mobile/desktop UX
- Default theme to system mode with live OS color-scheme detection
- Fix custom dot-matrix sprite cursor for light/dark modes
- Preserve natural color portraits across all views
- Improve mobile hero layout with responsive portrait and Geist Pixel typography
- Add subtle color tint to perimeter background dots while keeping reading content clear
- Center certificate modal via createPortal and make verify button theme-responsive
- Make settings controls (magnifier toggle, lens button, zoom levels) theme-responsive
- Remove flagship badge and deep-dive technical sections from TIS-RMS case study
2026-10-23 00:50:38 +08:00

441 lines
21 KiB
TypeScript

import React, { useEffect, useState, useRef } from 'react';
import { createPortal } from 'react-dom';
import haptic from '../lib/haptics';
import { useIsMobile } from '../lib/hooks';
import { switchTheme } from '../lib/theme';
type ThemeMode = 'system' | 'light' | 'dark';
export default function ControlsDropdown() {
const [isOpen, setIsOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [themeMode, setThemeMode] = useState<ThemeMode>('light');
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
const [magnifierEnabled, setMagnifierEnabled] = useState<boolean>(false);
const [zoom, setZoom] = useState<number>(100);
const [isTouchDevice, setIsTouchDevice] = useState<boolean>(false);
const modalRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile(768);
useEffect(() => {
setMounted(true);
if (typeof window === 'undefined') return;
setIsTouchDevice(window.matchMedia('(hover: none) and (pointer: coarse)').matches);
// Detect theme preferences: System default or manual stored
const stored = localStorage.getItem('theme') as ThemeMode | null;
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark') {
setThemeMode('dark');
setResolvedTheme('dark');
document.documentElement.classList.add('dark');
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '#0c0c0f');
} else if (stored === 'light') {
setThemeMode('light');
setResolvedTheme('light');
document.documentElement.classList.remove('dark');
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '#ffffff');
} else {
// Default: System Mode
setThemeMode('system');
const detectedTheme = systemDark ? 'dark' : 'light';
setResolvedTheme(detectedTheme);
if (detectedTheme === 'dark') {
document.documentElement.classList.add('dark');
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '#0c0c0f');
} else {
document.documentElement.classList.remove('dark');
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '#ffffff');
}
}
const savedMag = localStorage.getItem('magnifierEnabled');
setMagnifierEnabled(savedMag === 'true');
const savedZoom = localStorage.getItem('pageZoom');
if (savedZoom) {
const zoomVal = parseInt(savedZoom, 10);
if (!isNaN(zoomVal) && zoomVal >= 80 && zoomVal <= 130) {
setZoom(zoomVal);
document.documentElement.style.fontSize = `${(zoomVal / 100) * 15}px`;
}
}
// System theme change listener
const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
const currentStored = localStorage.getItem('theme');
if (!currentStored || currentStored === 'system') {
const isDark = e.matches;
setResolvedTheme(isDark ? 'dark' : 'light');
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
};
if (darkQuery.addEventListener) {
darkQuery.addEventListener('change', handleSystemThemeChange);
}
const handleCustomThemeChange = (e: CustomEvent<{ theme: 'light' | 'dark' }>) => {
if (e.detail?.theme) {
setResolvedTheme(e.detail.theme);
}
};
window.addEventListener('theme-change', handleCustomThemeChange as EventListener);
return () => {
if (darkQuery.removeEventListener) {
darkQuery.removeEventListener('change', handleSystemThemeChange);
}
window.removeEventListener('theme-change', handleCustomThemeChange as EventListener);
};
}, []);
// Handle ESC key and backdrop scroll lock
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
haptic.tap();
setIsOpen(false);
}
};
if (isOpen) {
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
const toggleTheme = (mode: ThemeMode) => {
haptic.tap();
setThemeMode(mode);
let resolved: 'light' | 'dark' = 'dark';
if (mode === 'system') {
localStorage.setItem('theme', 'system');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
resolved = systemDark ? 'dark' : 'light';
} else {
localStorage.setItem('theme', mode);
resolved = mode;
}
setResolvedTheme(resolved);
switchTheme(resolved);
if (mode === 'system') {
localStorage.setItem('theme', 'system');
}
};
const toggleMagnifier = () => {
haptic.tick();
const nextVal = !magnifierEnabled;
setMagnifierEnabled(nextVal);
localStorage.setItem('magnifierEnabled', String(nextVal));
window.dispatchEvent(new CustomEvent('magnifier-preference-changed', { detail: { enabled: nextVal } }));
};
const triggerInstantMagnify = () => {
haptic.tap();
window.dispatchEvent(new CustomEvent('toggle-magnifier-mode'));
};
const changeZoom = (newZoom: number) => {
haptic.tick();
const clamped = Math.max(80, Math.min(130, newZoom));
setZoom(clamped);
document.documentElement.style.fontSize = `${(clamped / 100) * 15}px`;
localStorage.setItem('pageZoom', String(clamped));
};
return (
<>
{/* Trigger Button */}
<button
type="button"
onClick={() => {
haptic.tap();
setIsOpen(true);
}}
aria-label="Settings"
aria-haspopup="dialog"
aria-expanded={isOpen}
className={`group p-2 sm:px-3 sm:py-1.5 rounded-xl border border-border-custom bg-card-custom hover:border-primary-500/50 hover:bg-primary-500/10 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-1.5 text-xs font-medium text-foreground-custom focus:outline-none cursor-pointer shadow-xs active:translate-y-px ${
isOpen ? 'bg-primary-500/15 border-primary-500/50 text-primary-600 dark:text-primary-400' : ''
}`}
>
<div className="w-4 h-4 flex items-center justify-center shrink-0">
<svg className="w-4 h-4 transition-transform duration-300 group-hover:rotate-45" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<span className="hidden sm:inline text-[11px] font-mono font-medium">
Settings
</span>
<svg
className="w-3 h-3 shrink-0 stroke-[2] opacity-70"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
{/* Accessibility Modal Dialog (Portaled to document.body) */}
{isOpen && mounted && typeof document !== 'undefined' && createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 md:p-6 bg-black/60 backdrop-blur-xs animate-modal-fade"
onClick={() => {
haptic.tap();
setIsOpen(false);
}}
role="dialog"
aria-modal="true"
aria-labelledby="accessibility-modal-title"
>
<div
ref={modalRef}
className="relative w-full max-w-sm sm:max-w-md rounded-2xl bg-card-custom border border-border-custom shadow-xl p-4 sm:p-6 flex flex-col gap-3.5 sm:gap-4 text-foreground-custom animate-modal-scale max-h-[88vh] overflow-y-auto my-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Modal Header */}
<div className="flex items-center justify-between pb-3 border-b border-border-custom">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-primary-500/10 text-primary-600 dark:text-primary-400 border border-primary-500/20 flex items-center justify-center shrink-0">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<h2 id="accessibility-modal-title" className="text-sm font-heading font-semibold tracking-tight text-foreground-custom uppercase">
Settings
</h2>
<p className="text-[11px] text-muted-foreground-custom font-mono">
Appearance, text zoom &amp; preferences
</p>
</div>
</div>
<button
type="button"
onClick={() => {
haptic.tap();
setIsOpen(false);
}}
aria-label="Close settings modal"
className="w-8 h-8 rounded-lg flex items-center justify-center text-muted-foreground-custom hover:text-foreground-custom bg-muted-custom/50 hover:bg-muted-custom border border-border-custom transition-all cursor-pointer active:scale-95"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Section 1: Appearance Mode (Auto / Light / Dark) */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-mono font-semibold uppercase tracking-wider text-muted-foreground-custom">
Appearance Mode
</span>
<span className="text-[11px] font-mono font-semibold text-foreground-custom capitalize flex items-center gap-1">
{themeMode === 'system' ? `Auto (${resolvedTheme})` : themeMode}
</span>
</div>
<div className="grid grid-cols-3 gap-1.5 p-1 bg-muted-custom/60 rounded-xl border border-border-custom">
{/* Auto / System (Default) */}
<button
type="button"
onClick={() => toggleTheme('system')}
className={`flex items-center justify-center gap-1.5 py-1.5 px-2 rounded-lg text-xs transition-all cursor-pointer ${
themeMode === 'system'
? 'bg-foreground-custom text-background-custom border border-foreground-custom shadow-xs font-semibold'
: 'text-muted-foreground-custom hover:text-foreground-custom hover:bg-card-custom/50 border border-transparent font-medium'
}`}
title="Detect and match your device system theme"
>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0H3" />
</svg>
<span>Auto</span>
</button>
{/* Light */}
<button
type="button"
onClick={() => toggleTheme('light')}
className={`flex items-center justify-center gap-1.5 py-1.5 px-2 rounded-lg text-xs transition-all cursor-pointer ${
themeMode === 'light'
? 'bg-foreground-custom text-background-custom border border-foreground-custom shadow-xs font-semibold'
: 'text-muted-foreground-custom hover:text-foreground-custom hover:bg-card-custom/50 border border-transparent font-medium'
}`}
>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="4" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2v2m0 16v2m10-10h-2M4 12H2m15.364-7.364l-1.414 1.414M7.05 16.95l-1.414 1.414m12.728 0l-1.414-1.414M7.05 7.05L5.636 5.636" />
</svg>
<span>Light</span>
</button>
{/* Dark */}
<button
type="button"
onClick={() => toggleTheme('dark')}
className={`flex items-center justify-center gap-1.5 py-1.5 px-2 rounded-lg text-xs transition-all cursor-pointer ${
themeMode === 'dark'
? 'bg-foreground-custom text-background-custom border border-foreground-custom shadow-xs font-semibold'
: 'text-muted-foreground-custom hover:text-foreground-custom hover:bg-card-custom/50 border border-transparent font-medium'
}`}
>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3a6 6 0 009 9 9 9 0 11-9-9z" />
</svg>
<span>Dark</span>
</button>
</div>
</div>
<div className="h-px bg-border-custom" />
{/* Section 2: Magnifier Feature (Hold 'R' / Toggle) - Hidden on Mobile */}
{!isMobile && !isTouchDevice && (
<>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-mono font-semibold uppercase tracking-wider text-muted-foreground-custom">
Magnifier Lens (Key 'R')
</span>
<span className={`text-[10px] font-mono font-medium px-2 py-0.5 rounded-full border ${
magnifierEnabled ? 'bg-foreground-custom text-background-custom border-foreground-custom' : 'bg-muted-custom text-muted-foreground-custom border-border-custom'
}`}>
{magnifierEnabled ? 'ENABLED' : 'DISABLED'}
</span>
</div>
<div className="flex items-center justify-between gap-2 p-3 bg-muted-custom/30 rounded-xl border border-border-custom">
<div className="flex flex-col">
<span className="text-xs font-semibold text-foreground-custom">
Hold 'R' to Magnify
</span>
<span className="text-[11px] text-muted-foreground-custom">
Magnifies content directly under mouse pointer
</span>
</div>
<button
type="button"
onClick={toggleMagnifier}
className={`relative inline-flex h-5 w-10 items-center rounded-full transition-colors cursor-pointer border ${
magnifierEnabled ? 'bg-foreground-custom border-foreground-custom' : 'bg-muted-custom border-border-custom'
}`}
aria-label="Toggle magnifier"
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full shadow-xs transition-transform ${
magnifierEnabled ? 'bg-background-custom translate-x-5' : 'bg-muted-foreground-custom translate-x-0.5'
}`}
/>
</button>
</div>
{magnifierEnabled && (
<button
type="button"
onClick={triggerInstantMagnify}
className="w-full py-2 px-3 rounded-xl text-xs font-mono font-medium bg-foreground-custom text-background-custom border border-foreground-custom shadow-xs hover:opacity-90 transition-all flex items-center justify-center gap-1.5 cursor-pointer active:translate-y-px"
>
<svg className="w-3.5 h-3.5 stroke-[2]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607zM10.5 7.5v6m3-3h-6" />
</svg>
Toggle Magnifier Lens Mode
</button>
)}
</div>
<div className="h-px bg-border-custom" />
</>
)}
{/* Section 3: Page Text Zoom */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-mono font-semibold uppercase tracking-wider text-muted-foreground-custom">
Page Text Zoom
</span>
<span className="text-[11px] font-mono font-semibold text-foreground-custom">
{zoom}%
</span>
</div>
<div className="grid grid-cols-3 gap-2">
{[85, 100, 115].map((level) => (
<button
key={level}
type="button"
onClick={() => changeZoom(level)}
className={`py-1.5 px-2 rounded-lg text-xs font-mono font-medium transition-all border cursor-pointer active:translate-y-px ${
zoom === level
? 'bg-foreground-custom text-background-custom border-foreground-custom shadow-xs font-semibold'
: 'bg-card-custom text-foreground-custom border-border-custom shadow-xs hover:bg-elevated-custom'
}`}
>
{level === 100 ? '100% (Def)' : `${level}%`}
</button>
))}
</div>
<div className="flex items-center justify-between gap-2 mt-1">
<button
type="button"
onClick={() => changeZoom(zoom - 5)}
disabled={zoom <= 80}
className="flex-1 py-1.5 rounded-lg bg-card-custom hover:bg-elevated-custom text-foreground-custom font-semibold text-sm disabled:opacity-40 transition-all flex items-center justify-center cursor-pointer border border-border-custom shadow-xs active:translate-y-px"
aria-label="Decrease text zoom"
>
-
</button>
<button
type="button"
onClick={() => changeZoom(100)}
className="px-3 py-1.5 text-xs font-mono font-medium text-muted-foreground-custom hover:text-foreground-custom cursor-pointer"
>
Reset
</button>
<button
type="button"
onClick={() => changeZoom(zoom + 5)}
disabled={zoom >= 130}
className="flex-1 py-1.5 rounded-lg bg-card-custom hover:bg-elevated-custom text-foreground-custom font-semibold text-sm disabled:opacity-40 transition-all flex items-center justify-center cursor-pointer border border-border-custom shadow-xs active:translate-y-px"
aria-label="Increase text zoom"
>
+
</button>
</div>
</div>
</div>
</div>,
document.body
)}
</>
);
}