- Set up class-based dark mode in Tailwind config - Add pre-hydration dark mode application to avoid theme flash on initial load - Implement accessible dark mode toggle in settings page - Update all UI surfaces (headers, bottom nav, maps) with dark mode color schemes - Add comprehensive global CSS overrides for arbitrary Tailwind classes - Add brand accent strips to header and bottom nav for consistent visual framing - Refine map waypoint styling: reduce dot size, update label positioning and styling
363 lines
No EOL
15 KiB
TypeScript
363 lines
No EOL
15 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useAuth } from '@/components/AuthProvider';
|
|
import BottomNav from '@/components/BottomNav';
|
|
import { getSettings, saveSettings, logout } from '@/lib/pocketbase';
|
|
import { AppSettings, DEFAULT_SETTINGS } from '@/lib/types';
|
|
import { cn } from '@/lib/utils';
|
|
import { setDarkMode, applyDarkMode } from '@/lib/theme';
|
|
import {
|
|
Settings,
|
|
Clock,
|
|
Crosshair,
|
|
LogOut,
|
|
Save,
|
|
RotateCcw,
|
|
Info,
|
|
ChevronRight,
|
|
Signal,
|
|
MapPin,
|
|
Shield,
|
|
Moon,
|
|
Sun,
|
|
} from 'lucide-react';
|
|
|
|
const INTERVAL_OPTIONS = [
|
|
{ value: 30, label: '30s' },
|
|
{ value: 60, label: '1 min' },
|
|
{ value: 120, label: '2 min' },
|
|
{ value: 300, label: '5 min' },
|
|
{ value: 600, label: '10 min' },
|
|
];
|
|
|
|
const ACCURACY_OPTIONS = [
|
|
{ value: 10, label: '10m', desc: 'High precision' },
|
|
{ value: 50, label: '50m', desc: 'Balanced' },
|
|
{ value: 100, label: '100m', desc: 'Battery saver' },
|
|
{ value: 500, label: '500m', desc: 'Low power' },
|
|
];
|
|
|
|
export default function SettingsPage() {
|
|
const router = useRouter();
|
|
const { isLoggedIn, user, isLoading: authLoading } = useAuth();
|
|
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && !isLoggedIn) router.push('/login/');
|
|
}, [isLoggedIn, authLoading, router]);
|
|
|
|
useEffect(() => {
|
|
setSettings(getSettings());
|
|
}, []);
|
|
|
|
const updateSetting = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
|
|
const updated = { ...settings, [key]: value };
|
|
setSettings(updated);
|
|
saveSettings({ [key]: value });
|
|
// Dark mode also needs to flip the <html> class — the rest of
|
|
// AppSettings is purely persisted, but darkMode drives a class
|
|
// Tailwind keys off of, so we apply it inline here.
|
|
if (key === 'darkMode') setDarkMode(Boolean(value));
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 2000);
|
|
};
|
|
|
|
const resetSettings = () => {
|
|
if (confirm('Reset all settings to defaults?')) {
|
|
setSettings(DEFAULT_SETTINGS);
|
|
saveSettings(DEFAULT_SETTINGS);
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 2000);
|
|
}
|
|
};
|
|
|
|
if (authLoading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-[#f2f1ed]">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="w-10 h-10 border-[3px] border-[#f54e00] border-t-transparent rounded-full animate-spin" />
|
|
<p className="text-sm text-[rgba(38,37,30,0.55)]">Loading...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isLoggedIn) return null;
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#f2f1ed] pb-nav">
|
|
{/* Header — Cursor cream surface, dark ink icon tile */}
|
|
<header className="bg-[#e6e5e0] text-[#26251e] safe-top border-b border-[rgba(38,37,30,0.1)]">
|
|
<div className="px-4 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-12 h-12 bg-[#26251e] rounded-2xl flex items-center justify-center" style={{ boxShadow: '0 4px 12px rgba(0,0,0,0.15)' }}>
|
|
<Settings size={22} className="text-white" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h1 className="text-lg font-bold text-[#26251e]">Settings</h1>
|
|
<p className="text-xs text-[rgba(38,37,30,0.55)] truncate">{user?.name || user?.email}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="p-4 space-y-6">
|
|
{/* Location Settings */}
|
|
<section>
|
|
<div className="flex items-center gap-2 mb-3 px-1">
|
|
<Signal size={14} className="text-[#f54e00]" />
|
|
<h2 className="text-xs font-bold text-[rgba(38,37,30,0.55)] uppercase tracking-wider">
|
|
Location Tracking
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="bg-[#e6e5e0] rounded-2xl overflow-hidden border border-[rgba(38,37,30,0.1)]">
|
|
{/* Update Interval */}
|
|
<div className="p-4 border-b border-[rgba(38,37,30,0.08)]">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<Clock size={16} className="text-[rgba(38,37,30,0.55)]" />
|
|
<span className="text-sm font-semibold text-[#26251e]">Ping Interval</span>
|
|
</div>
|
|
<span className="text-xs font-bold text-[#f54e00] bg-[#ebeae5] px-2.5 py-1 rounded-lg">
|
|
{settings.locationInterval >= 60 ? `${settings.locationInterval / 60} min` : `${settings.locationInterval}s`}
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-5 gap-1.5">
|
|
{INTERVAL_OPTIONS.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
onClick={() => updateSetting('locationInterval', option.value)}
|
|
aria-pressed={settings.locationInterval === option.value}
|
|
className={cn(
|
|
'py-2.5 rounded-xl text-xs font-semibold transition-all',
|
|
'touch-manipulation press-feedback-sm',
|
|
settings.locationInterval === option.value
|
|
? 'bg-[#26251e] text-white'
|
|
: 'bg-[#ebeae5] text-[rgba(38,37,30,0.7)] active:bg-[#e1e0db]'
|
|
)}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Minimum Accuracy */}
|
|
<div className="p-4 border-b border-[rgba(38,37,30,0.08)]">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<Crosshair size={16} className="text-[rgba(38,37,30,0.55)]" />
|
|
<span className="text-sm font-semibold text-[#26251e]">Accuracy Filter</span>
|
|
</div>
|
|
<span className="text-xs font-bold text-[#f54e00] bg-[#ebeae5] px-2.5 py-1 rounded-lg">
|
|
{settings.minimumAccuracy}m
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
{ACCURACY_OPTIONS.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
onClick={() => updateSetting('minimumAccuracy', option.value)}
|
|
aria-pressed={settings.minimumAccuracy === option.value}
|
|
className={cn(
|
|
'py-2.5 rounded-xl text-xs font-semibold transition-all flex flex-col items-center',
|
|
'touch-manipulation press-feedback-sm',
|
|
settings.minimumAccuracy === option.value
|
|
? 'bg-[#26251e] text-white'
|
|
: 'bg-[#ebeae5] text-[rgba(38,37,30,0.7)] active:bg-[#e1e0db]'
|
|
)}
|
|
>
|
|
<span>{option.label}</span>
|
|
<span className={cn(
|
|
'text-[9px] mt-0.5',
|
|
settings.minimumAccuracy === option.value ? 'text-[rgba(242,241,237,0.7)]' : 'text-[rgba(38,37,30,0.55)]'
|
|
)}>
|
|
{option.desc}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* High Accuracy Toggle */}
|
|
<div className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 bg-[#ebeae5] rounded-xl flex items-center justify-center">
|
|
<MapPin size={16} className="text-[#26251e]" />
|
|
</div>
|
|
<div>
|
|
<span className="text-sm font-semibold text-[#26251e] block">High Accuracy GPS</span>
|
|
<span className="text-[11px] text-[rgba(38,37,30,0.55)]">Uses more battery for precision</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => updateSetting('enableHighAccuracy', !settings.enableHighAccuracy)}
|
|
role="switch"
|
|
aria-checked={settings.enableHighAccuracy}
|
|
aria-label="High Accuracy GPS"
|
|
className={cn(
|
|
'w-[52px] h-[30px] rounded-full transition-colors relative',
|
|
settings.enableHighAccuracy ? 'bg-[#f54e00]' : 'bg-[rgba(38,37,30,0.15)]'
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
'absolute top-[3px] w-6 h-6 bg-white rounded-full shadow-md transition-transform',
|
|
settings.enableHighAccuracy ? 'translate-x-[26px]' : 'translate-x-[3px]'
|
|
)} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Screen Wake Lock */}
|
|
<div className="p-4 border-t border-[rgba(38,37,30,0.08)]">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 bg-[#e6f1ec] rounded-xl flex items-center justify-center">
|
|
<Shield size={16} className="text-[#125a3e]" />
|
|
</div>
|
|
<div>
|
|
<span className="text-sm font-semibold text-[#26251e] block">Keep Screen On</span>
|
|
<span className="text-[11px] text-[rgba(38,37,30,0.55)]">Prevents phone from sleeping</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => updateSetting('offlineMode', !settings.offlineMode)}
|
|
role="switch"
|
|
aria-checked={settings.offlineMode}
|
|
aria-label="Keep screen on"
|
|
className={cn(
|
|
'w-[52px] h-[30px] rounded-full transition-colors relative',
|
|
settings.offlineMode ? 'bg-[#f54e00]' : 'bg-[rgba(38,37,30,0.15)]'
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
'absolute top-[3px] w-6 h-6 bg-white rounded-full shadow-md transition-transform',
|
|
settings.offlineMode ? 'translate-x-[26px]' : 'translate-x-[3px]'
|
|
)} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Appearance */}
|
|
<section>
|
|
<div className="flex items-center gap-2 mb-3 px-1">
|
|
{settings.darkMode
|
|
? <Moon size={14} className="text-[#f54e00]" />
|
|
: <Sun size={14} className="text-[#f54e00]" />}
|
|
<h2 className="text-xs font-bold text-[rgba(38,37,30,0.55)] uppercase tracking-wider">
|
|
Appearance
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="bg-[#e6e5e0] rounded-2xl overflow-hidden border border-[rgba(38,37,30,0.1)]">
|
|
<div className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 bg-[#ebeae5] rounded-xl flex items-center justify-center">
|
|
{settings.darkMode
|
|
? <Moon size={16} className="text-[#26251e]" />
|
|
: <Sun size={16} className="text-[#f54e00]" />}
|
|
</div>
|
|
<div>
|
|
<span className="text-sm font-semibold text-[#26251e] block">Dark Mode</span>
|
|
<span className="text-[11px] text-[rgba(38,37,30,0.55)]">Easier on the eyes at night</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => updateSetting('darkMode', !settings.darkMode)}
|
|
role="switch"
|
|
aria-checked={settings.darkMode}
|
|
aria-label="Dark mode"
|
|
className={cn(
|
|
'w-[52px] h-[30px] rounded-full transition-colors relative',
|
|
settings.darkMode ? 'bg-[#f54e00]' : 'bg-[rgba(38,37,30,0.15)]'
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
'absolute top-[3px] w-6 h-6 bg-white rounded-full shadow-md transition-transform',
|
|
settings.darkMode ? 'translate-x-[26px]' : 'translate-x-[3px]'
|
|
)} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* About */}
|
|
<section>
|
|
<div className="flex items-center gap-2 mb-3 px-1">
|
|
<Info size={14} className="text-[#f54e00]" />
|
|
<h2 className="text-xs font-bold text-[rgba(38,37,30,0.55)] uppercase tracking-wider">About</h2>
|
|
</div>
|
|
|
|
<div className="bg-[#e6e5e0] rounded-2xl overflow-hidden border border-[rgba(38,37,30,0.1)]">
|
|
<div className="p-5 text-center">
|
|
<div className="w-14 h-14 bg-[#26251e] rounded-2xl mx-auto mb-3 flex items-center justify-center" style={{ boxShadow: '0 4px 12px rgba(0,0,0,0.15)' }}>
|
|
<span className="text-white font-bold text-xl">1D</span>
|
|
</div>
|
|
<h3 className="text-lg font-bold text-[#26251e]">1Driver</h3>
|
|
<p className="text-xs text-[rgba(38,37,30,0.55)] mt-1">Version 1.0.0</p>
|
|
<p className="text-[11px] text-[rgba(38,37,30,0.4)] mt-0.5">Collection Management App</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Actions */}
|
|
<div className="space-y-2.5">
|
|
<button
|
|
onClick={resetSettings}
|
|
className={cn(
|
|
'w-full py-3.5 px-4 rounded-2xl text-sm font-semibold',
|
|
'bg-[#e6e5e0] border border-[rgba(38,37,30,0.1)] text-[#26251e] active:bg-[#ebeae5]',
|
|
'touch-manipulation press-feedback flex items-center justify-center gap-2'
|
|
)}
|
|
>
|
|
<RotateCcw size={16} />
|
|
Reset to Defaults
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => {
|
|
if (confirm('Are you sure you want to sign out?')) {
|
|
logout();
|
|
}
|
|
}}
|
|
className={cn(
|
|
'w-full py-3.5 px-4 rounded-2xl text-white font-semibold text-[15px]',
|
|
'bg-[#cf2d56] active:bg-[#8a1b3a] transition-colors',
|
|
'touch-manipulation press-feedback flex items-center justify-center gap-2'
|
|
)}
|
|
style={{ boxShadow: '0 4px 12px rgba(207,45,86,0.3)' }}
|
|
>
|
|
<LogOut size={18} />
|
|
Sign Out
|
|
</button>
|
|
</div>
|
|
</main>
|
|
|
|
{/* Saved Toast */}
|
|
{saved && (
|
|
<div className="fixed top-4 left-4 right-4 z-[70] animate-slide-down safe-top">
|
|
<div
|
|
className="px-4 py-3 rounded-2xl text-sm font-medium text-white text-center bg-[#1f8a65] flex items-center justify-center gap-2"
|
|
role="status"
|
|
aria-live="polite"
|
|
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18)' }}
|
|
>
|
|
<Save size={16} />
|
|
Settings saved
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<BottomNav />
|
|
</div>
|
|
);
|
|
} |