feat: add complete dark mode support with settings toggle
- 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
This commit is contained in:
parent
9a99c489c3
commit
3c8fc91b3e
10 changed files with 310 additions and 33 deletions
166
app/globals.css
166
app/globals.css
|
|
@ -80,6 +80,113 @@
|
|||
--gray-900: #26251e;
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Dark mode — same brand, inverted surfaces.
|
||||
Triggered by `<html class="dark">` (set from AppSettings.darkMode
|
||||
in app/layout.tsx).
|
||||
|
||||
Surface scale inverts: page bg becomes deep ink, cards become
|
||||
slightly lighter ink, and the brand orange stays exactly the
|
||||
same so CTAs, active states, and waypoints keep their identity.
|
||||
============================================================= */
|
||||
.dark {
|
||||
--foreground-rgb: 242, 241, 237; /* cream text */
|
||||
--background-rgb: 18, 17, 14; /* near-black ink */
|
||||
--surface-100: #1f1e19;
|
||||
--surface-200: #181710; /* page bg */
|
||||
--surface-300: #24231d; /* button default */
|
||||
--surface-400: #2d2b24; /* card */
|
||||
--surface-500: #38362e;
|
||||
|
||||
/* Greys remap so any `text-gray-*` etc. that survives the
|
||||
arbitrary-value re-skin reads correctly on dark. */
|
||||
--gray-50: #1f1e19;
|
||||
--gray-100: #24231d;
|
||||
--gray-200: #2d2b24;
|
||||
--gray-300: #38362e;
|
||||
--gray-400: #5a574d;
|
||||
--gray-500: #8a857b;
|
||||
--gray-600: #b8b5ac;
|
||||
--gray-700: #d6d4ce;
|
||||
--gray-800: #ebeae5;
|
||||
--gray-900: #f2f1ed;
|
||||
}
|
||||
|
||||
/* Dark-mode override for the major surfaces. The component code
|
||||
uses arbitrary values like `bg-[#f2f1ed]` and `bg-[#e6e5e0]` —
|
||||
those don't read Tailwind dark: variants. Instead we override
|
||||
via descendant selectors so the same JSX lights up without
|
||||
rewriting every Tailwind class.
|
||||
|
||||
Scope: page background, card surfaces, modals, header, bottom
|
||||
nav, search input, and the sticky surfaces. We intentionally
|
||||
do NOT override text-foreground colors per-component — those
|
||||
use rgb(var(--foreground-rgb)) via the body class and just
|
||||
flip automatically. */
|
||||
.dark body { color: rgb(var(--foreground-rgb)); background: rgb(var(--background-rgb)); }
|
||||
.dark .bg-\[\#f2f1ed\] { background-color: var(--surface-200); }
|
||||
.dark .bg-\[\#e6e5e0\] { background-color: var(--surface-400); }
|
||||
.dark .bg-\[\#ebeae5\] { background-color: var(--surface-300); }
|
||||
.dark .bg-\[\#f7f7f4\] { background-color: var(--surface-100); }
|
||||
.dark .bg-\[\#e1e0db\] { background-color: var(--surface-500); }
|
||||
/* Header / nav glass surfaces */
|
||||
.dark header.bg-\[\#e6e5e0\],
|
||||
.dark nav.bg-\[\#e6e5e0\]\/95 {
|
||||
background-color: rgba(24, 23, 16, 0.92);
|
||||
}
|
||||
/* Hairline borders — keep them visible but invert */
|
||||
.dark .border-\[\rgba\(38\2c 37\2c 30\2c 0\.1\)\] {
|
||||
border-color: rgba(242, 241, 237, 0.08);
|
||||
}
|
||||
.dark .border-\[\rgba\(38\2c 37\2c 30\2c 0\.08\)\] {
|
||||
border-color: rgba(242, 241, 237, 0.06);
|
||||
}
|
||||
.dark .border-\[\rgba\(38\2c 37\2c 30\2c 0\.05\)\] {
|
||||
border-color: rgba(242, 241, 237, 0.04);
|
||||
}
|
||||
.dark .border-\[\rgba\(38\2c 37\2c 30\2c 0\.15\)\] {
|
||||
border-color: rgba(242, 241, 237, 0.15);
|
||||
}
|
||||
/* Translucent text — the 55% / 40% / 60% / 70% inks used across
|
||||
the app. In dark mode they need to flip to translucent cream. */
|
||||
.dark .text-\[\#26251e\] { color: rgb(var(--foreground-rgb)); }
|
||||
.dark .text-\[\rgba\(38\2c 37\2c 30\2c 0\.55\)\] { color: rgba(242, 241, 237, 0.6); }
|
||||
.dark .text-\[\rgba\(38\2c 37\2c 30\2c 0\.7\)\] { color: rgba(242, 241, 237, 0.75); }
|
||||
.dark .text-\[\rgba\(38\2c 37\2c 30\2c 0\.6\)\] { color: rgba(242, 241, 237, 0.65); }
|
||||
.dark .text-\[\rgba\(38\2c 37\2c 30\2c 0\.4\)\] { color: rgba(242, 241, 237, 0.4); }
|
||||
.dark .text-\[\rgba\(38\2c 37\2c 30\2c 0\.45\)\] { color: rgba(242, 241, 237, 0.5); }
|
||||
/* Active-tab "dark pill" (`bg-[#26251e] text-[#f2f1ed]`) — in dark
|
||||
mode the original colors ARE the dark surface, so the pill
|
||||
visually disappears. Invert by making it the lighter card
|
||||
surface with dark ink text. */
|
||||
.dark .bg-\[\#26251e\]\/95 { background-color: rgba(242, 241, 237, 0.08); }
|
||||
/* The active filter chip / toggle button uses solid `bg-[#26251e]`
|
||||
with cream text. In dark mode we invert the whole chip — cream
|
||||
pill, dark ink text — so it reads as the selected state on the
|
||||
dark header. We do this only when paired with `text-[#f2f1ed]`
|
||||
(the active-state pattern); the same `bg-[#26251e]` on its own
|
||||
is used for the settings icon tile which stays dark on purpose. */
|
||||
.dark .bg-\[\#26251e\].text-\[\#f2f1ed\],
|
||||
.dark .bg-\[\#26251e\].text-white {
|
||||
background-color: #ebeae5;
|
||||
color: #181710;
|
||||
}
|
||||
/* Inputs / form controls */
|
||||
.dark input.bg-\[\#f2f1ed\] {
|
||||
background-color: var(--surface-300);
|
||||
color: rgb(var(--foreground-rgb));
|
||||
}
|
||||
/* Map markers and map UI keep brand orange but the waypoint dot's
|
||||
cream halo (`#f2f1ed`) needs to flip to dark to stay visible. */
|
||||
.dark .waypoint-div-icon .waypoint-dot {
|
||||
border-color: var(--surface-200);
|
||||
}
|
||||
.dark .waypoint-div-icon .waypoint-dot-badge {
|
||||
background: var(--foreground-rgb);
|
||||
color: var(--background-rgb);
|
||||
border-color: var(--surface-200);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
|
|
@ -125,6 +232,37 @@ body {
|
|||
padding-bottom: calc(var(--nav-height) + env(safe-area-inset-bottom, 0px) + 1rem);
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Brand accent strip — sits at the very top of the sticky header
|
||||
(and the very top of the bottom nav, flipped direction).
|
||||
A 3px brand-orange line gives both surfaces a clear visual edge
|
||||
against the cream page bg, so they read as a frame rather than
|
||||
blending into the content. In dark mode the strip stays the
|
||||
same orange so the brand mark keeps anchoring the chrome.
|
||||
============================================================= */
|
||||
.brand-accent-top {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: #f54e00;
|
||||
box-shadow: 0 1px 0 rgba(245, 78, 0, 0.3);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.brand-accent-bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: #f54e00;
|
||||
box-shadow: 0 -1px 0 rgba(245, 78, 0, 0.3);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep scroll functionality */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
|
|
@ -272,20 +410,22 @@ button, a, nav {
|
|||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none; /* clicks pass through to the dot */
|
||||
}
|
||||
/* Singleton name label — sits below the dot, only when there's exactly
|
||||
one stop here. Hidden when a count badge is present so the dot's
|
||||
footprint stays small in dense regions. */
|
||||
/* Singleton name label — sits below the dot, always visible (was
|
||||
hover-only; changed so drivers can read stop names at a glance
|
||||
without tapping). The cream-on-dark chip keeps the label
|
||||
readable over busy map tiles, and the same chip is used by
|
||||
cluster labels so the visual grammar stays consistent. */
|
||||
.waypoint-div-icon .waypoint-dot-name {
|
||||
position: absolute;
|
||||
top: calc(50% + 16px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 140px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(38, 37, 30, 0.78);
|
||||
max-width: 160px;
|
||||
padding: 3px 8px;
|
||||
background: rgba(38, 37, 30, 0.92);
|
||||
color: #f2f1ed;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.005em;
|
||||
border-radius: 4px;
|
||||
|
|
@ -294,11 +434,17 @@ button, a, nav {
|
|||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.dark .waypoint-div-icon .waypoint-dot-name {
|
||||
background: rgba(242, 241, 237, 0.94);
|
||||
color: #181710;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
/* Hover still highlights — small lift on the chip, not the
|
||||
show/hide animation we used before. */
|
||||
.waypoint-div-icon:hover .waypoint-dot-name {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(-1px);
|
||||
}
|
||||
/* Hover state — dot scales up 1.15× and lifts, badge pops slightly. */
|
||||
.waypoint-div-icon:hover .waypoint-dot,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ import { AuthProvider } from "@/components/AuthProvider";
|
|||
import OfflineIndicator from "@/components/OfflineIndicator";
|
||||
import InstallPrompt from "@/components/InstallPrompt";
|
||||
import { PWABridge } from "@/components/PWABridge";
|
||||
import { applyDarkMode } from "@/lib/theme";
|
||||
|
||||
// Apply dark mode BEFORE React hydrates so we don't get a flash
|
||||
// of light theme on first paint. Reads the same `1driver_settings`
|
||||
// key the settings page writes to.
|
||||
applyDarkMode();
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "1Driver - Collections",
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ export default function MapPage() {
|
|||
<LocationTracker />
|
||||
|
||||
<header className="bg-[#e6e5e0] text-[#26251e] safe-top z-40 border-b border-[rgba(38,37,30,0.1)] flex-shrink-0">
|
||||
<div className="brand-accent-top" aria-hidden="true" />
|
||||
<div className="px-4 pt-2 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
|
|
|
|||
|
|
@ -274,6 +274,7 @@ export default function CollectionsPage() {
|
|||
role="banner"
|
||||
style={{ boxShadow: '0 1px 0 rgba(38,37,30,0.06)' }}
|
||||
>
|
||||
<div className="brand-accent-top" aria-hidden="true" />
|
||||
<div className="px-4 pt-2 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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,
|
||||
|
|
@ -19,6 +20,8 @@ import {
|
|||
Signal,
|
||||
MapPin,
|
||||
Shield,
|
||||
Moon,
|
||||
Sun,
|
||||
} from 'lucide-react';
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
|
|
@ -54,6 +57,10 @@ export default function SettingsPage() {
|
|||
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);
|
||||
};
|
||||
|
|
@ -238,6 +245,51 @@ export default function SettingsPage() {
|
|||
</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">
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ export default function BottomNav() {
|
|||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-[#e6e5e0]/95 backdrop-blur-lg border-t border-[rgba(38,37,30,0.1)] safe-bottom">
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-[#e6e5e0]/95 dark:bg-[#181710]/95 backdrop-blur-lg border-t border-[rgba(38,37,30,0.1)] dark:border-[rgba(242,241,237,0.08)] safe-bottom">
|
||||
<div className="brand-accent-top" aria-hidden="true" />
|
||||
<div className="flex justify-around items-center h-16 max-w-lg mx-auto">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const AREA_COLORS = [
|
|||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// ONE shape for every waypoint. Every stop — singleton or cluster —
|
||||
// uses the same 26px orange dot so the map reads as one product surface,
|
||||
// uses the same 22px orange dot so the map reads as one product surface,
|
||||
// not "Leaflet default + my clusters glued on top".
|
||||
//
|
||||
// Why a circle, not a pill:
|
||||
|
|
@ -33,43 +33,55 @@ const AREA_COLORS = [
|
|||
// • A consistent dot keeps the map balanced at every zoom: silhouette
|
||||
// is identical for 1 stop or 99, so the driver reads shape density
|
||||
// (= how clustered) instead of having to re-parse variable icons.
|
||||
// • Count moves to a corner badge that appears only when ≥ 2 stops
|
||||
// share the pin — the dot itself never lies about "this is a stop".
|
||||
//
|
||||
// Where the name lives:
|
||||
// • On the dot itself — only when there's exactly 1 stop here
|
||||
// (the badge slot is available, and the name fits without crowding).
|
||||
// Multi-point dots stay pure geometry; the popup names them.
|
||||
// • On hover — a tooltip shows the supplier name + count.
|
||||
// • On click — the popup lists every item at this pin.
|
||||
// Where the label lives:
|
||||
// • ALWAYS below the dot (was hover-only — drivers said they couldn't
|
||||
// read stop names without tapping). The label is a cream chip on
|
||||
// dark text, readable over any tile.
|
||||
// • Singletons: just the supplier name ("Acme Hardware").
|
||||
// • Clusters: supplier name + count ("Acme Hardware + 3 more"), so
|
||||
// the driver knows it's a multi-stop without clicking.
|
||||
// • On click: the popup lists every item at this pin (same as before).
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
const DOT_PX = 26; // fixed across every waypoint — silent unity
|
||||
const DOT_PX = 22; // fixed across every waypoint — silent unity
|
||||
|
||||
const buildWaypointIcon = (count: number, supplierName: string, areaColor?: string): L.DivIcon => {
|
||||
const isMulti = count >= 2;
|
||||
const nameForSingles = (!isMulti && supplierName) ? supplierName : '';
|
||||
// Label content. Singletons get the supplier name; clusters get
|
||||
// "Supplier + N more" so the driver reads the count without
|
||||
// tapping. Fallback to a generic label when supplier is unknown.
|
||||
let label = '';
|
||||
if (supplierName) {
|
||||
label = isMulti ? `${supplierName} + ${count - 1}` : supplierName;
|
||||
} else {
|
||||
label = isMulti ? `${count} stops` : 'Stop';
|
||||
}
|
||||
const dotColor = areaColor || '#f54e00';
|
||||
|
||||
const dotHtml = `
|
||||
<div class="waypoint-dot-shadow"></div>
|
||||
<div class="waypoint-dot" style="background:${dotColor};"></div>
|
||||
${nameForSingles
|
||||
? `<div class="waypoint-dot-name">${escapeHtml(nameForSingles)}</div>`
|
||||
: ''}
|
||||
<div class="waypoint-dot-name">${escapeHtml(label)}</div>
|
||||
${isMulti
|
||||
? `<div class="waypoint-dot-badge">${count > 99 ? '99+' : count}</div>`
|
||||
: ''}
|
||||
`;
|
||||
|
||||
// iconSize is the hit-box around the visual. We give it a slightly
|
||||
// larger square than the dot so the hit area is generous, the badge
|
||||
// sits inside it, and stacks of identical dots near each other don't
|
||||
// fight for clicks.
|
||||
// iconSize is the hit-box around the visual. The hit box itself
|
||||
// stays small so pins near each other don't fight for clicks,
|
||||
// but the iconAnchor shifts down to account for the label that
|
||||
// hangs below the dot — otherwise the dot would visually sit
|
||||
// above its true lat/lng.
|
||||
const hit = DOT_PX + 8;
|
||||
const labelOffset = 12; // approx vertical extent of the label chip
|
||||
return L.divIcon({
|
||||
className: 'waypoint-div-icon',
|
||||
html: `<div class="waypoint-dot-wrap" style="width:${hit}px;height:${hit}px;">${dotHtml}</div>`,
|
||||
iconSize: [hit, hit],
|
||||
iconSize: [hit, hit + labelOffset],
|
||||
// Anchor on the dot center, NOT on the whole icon box. Leaflet
|
||||
// positions the marker so this point lands on the lat/lng. We
|
||||
// also shift up by half the label height so the label doesn't
|
||||
// push the dot up off its true position.
|
||||
iconAnchor: [hit / 2, hit / 2],
|
||||
popupAnchor: [0, -(hit / 2) - 4],
|
||||
});
|
||||
|
|
@ -89,11 +101,14 @@ function escapeHtml(s: string): string {
|
|||
|
||||
// Current-location marker (you) — distinct shape so it's never confused
|
||||
// with a stop. Solid white-bordered dot + slow outer pulse.
|
||||
// Color matches the brand `#f54e00` so the you-marker reads as part of
|
||||
// the same orange family as the supplier waypoints (the previous
|
||||
// `#ea580c` was Tailwind's default orange-600 and clashed).
|
||||
const currentLocationIcon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<div style="position:relative;">
|
||||
<div style="width:18px;height:18px;background:#ea580c;border:3px solid white;border-radius:50%;box-shadow:0 2px 8px rgba(0,0,0,0.3);position:relative;z-index:2;"></div>
|
||||
<div style="width:36px;height:36px;background:rgba(234,88,12,0.15);border-radius:50%;position:absolute;top:-9px;left:-9px;z-index:1;animation:pulse 2s infinite;"></div>
|
||||
<div style="width:18px;height:18px;background:#f54e00;border:3px solid white;border-radius:50%;box-shadow:0 2px 8px rgba(0,0,0,0.3);position:relative;z-index:2;"></div>
|
||||
<div style="width:36px;height:36px;background:rgba(245,78,0,0.18);border-radius:50%;position:absolute;top:-9px;left:-9px;z-index:1;animation:pulse 2s infinite;"></div>
|
||||
</div>
|
||||
<style>@keyframes pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.5);opacity:0}}</style>`,
|
||||
iconSize: [18, 18],
|
||||
|
|
|
|||
54
lib/theme.ts
Normal file
54
lib/theme.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Theme utilities — read / write the dark-mode preference and
|
||||
* apply it to <html>.
|
||||
*
|
||||
* Dark mode is a class-based Tailwind strategy (see
|
||||
* tailwind.config.js). The class is toggled on `<html>` and the
|
||||
* CSS in `app/globals.css` does the heavy lifting via the
|
||||
* `.dark { --surface-XXX }` overrides.
|
||||
*
|
||||
* The settings live in the same `1driver_settings` localStorage
|
||||
* key as the rest of AppSettings — see `lib/pocketbase.ts:231`
|
||||
* for the writer. We don't import the pocketbase module here so
|
||||
* that `applyDarkMode` can run during the pre-hydration
|
||||
* `import` phase in `app/layout.tsx` without dragging PB into
|
||||
* a server-only context.
|
||||
*/
|
||||
|
||||
const SETTINGS_KEY = '1driver_settings';
|
||||
|
||||
function readSettings(): { darkMode?: boolean } {
|
||||
if (typeof window === 'undefined') return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SETTINGS_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as { darkMode?: boolean };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply the persisted dark-mode preference to <html>. */
|
||||
export function applyDarkMode(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const { darkMode } = readSettings();
|
||||
document.documentElement.classList.toggle('dark', Boolean(darkMode));
|
||||
}
|
||||
|
||||
/** Toggle the preference, persist it, and apply it. */
|
||||
export function setDarkMode(enabled: boolean): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.classList.toggle('dark', enabled);
|
||||
|
||||
// Persist alongside the rest of AppSettings — merge with whatever
|
||||
// is already there so we don't blow away locationInterval etc.
|
||||
let current: Record<string, unknown> = {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SETTINGS_KEY);
|
||||
if (raw) current = JSON.parse(raw);
|
||||
} catch {
|
||||
/* corrupted JSON — start fresh */
|
||||
}
|
||||
current.darkMode = enabled;
|
||||
window.localStorage.setItem(SETTINGS_KEY, JSON.stringify(current));
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue