Add dark mode theme support across the UI
Introduce light/dark/system theme toggle with Bootstrap data-bs-theme and theme-aware surfaces and components. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -224,7 +224,8 @@
|
||||
}
|
||||
|
||||
/* Call log items */
|
||||
.bg-light.text-dark.border {
|
||||
.bg-light.text-dark.border,
|
||||
.bg-body-secondary.text-body-emphasis.border {
|
||||
background: white !important;
|
||||
border: 1px solid #666 !important;
|
||||
color: black !important;
|
||||
|
||||
+12
-10
@@ -13,6 +13,7 @@ import Search from './components/Search'
|
||||
import Summary from './components/Summary'
|
||||
import ChangePasswordModal from './components/ChangePasswordModal'
|
||||
import SettingsModal from './components/SettingsModal'
|
||||
import ThemeToggle from './components/ThemeToggle'
|
||||
import './App.css'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8085/api'
|
||||
@@ -190,7 +191,7 @@ function App() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="vh-100 d-flex flex-column bg-light">
|
||||
<div className="vh-100 d-flex flex-column bg-body-tertiary">
|
||||
{/* Header */}
|
||||
<header className="bg-primary bg-gradient text-white py-1 px-2 shadow" style={{zIndex: 1030}}>
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
@@ -201,6 +202,7 @@ function App() {
|
||||
<h1 className="h5 mb-0 fw-bold">SMS Backup Viewer</h1>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="btn btn-light btn-sm shadow-sm d-flex align-items-center gap-1"
|
||||
@@ -256,7 +258,7 @@ function App() {
|
||||
</header>
|
||||
|
||||
{/* View Switcher */}
|
||||
<div className="bg-white border-bottom shadow-sm">
|
||||
<div className="bg-body-tertiary border-bottom shadow-sm">
|
||||
<div className="container-fluid">
|
||||
<ul className="nav nav-tabs border-0">
|
||||
<li className="nav-item">
|
||||
@@ -319,7 +321,7 @@ function App() {
|
||||
</div>
|
||||
|
||||
{/* Date Filter */}
|
||||
<div className="date-filter-container bg-white border-bottom shadow-sm" style={{zIndex: 1025, position: 'relative'}}>
|
||||
<div className="date-filter-container bg-body-tertiary border-bottom shadow-sm" style={{zIndex: 1025, position: 'relative'}}>
|
||||
<DateFilter
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
@@ -336,9 +338,9 @@ function App() {
|
||||
<>
|
||||
{/* Conversation List */}
|
||||
<div
|
||||
className={`conversation-sidebar bg-white rounded-3 shadow overflow-hidden border ${showSidebar ? 'show' : ''}`}
|
||||
className={`conversation-sidebar bg-body-tertiary rounded-3 shadow overflow-hidden border ${showSidebar ? 'show' : ''}`}
|
||||
>
|
||||
<div className="bg-light border-bottom p-1">
|
||||
<div className="bg-body-tertiary border-bottom p-1">
|
||||
<h2 className="h6 mb-1 d-flex align-items-center gap-1 px-1">
|
||||
<svg style={{width: '1rem', height: '1rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
@@ -369,7 +371,7 @@ function App() {
|
||||
</div>
|
||||
|
||||
{/* Message Thread */}
|
||||
<div className="flex-fill bg-white rounded-3 shadow overflow-hidden border message-thread-container" style={{minWidth: 0}}>
|
||||
<div className="flex-fill bg-body-secondary rounded-3 shadow overflow-hidden border message-thread-container" style={{minWidth: 0}}>
|
||||
<MessageThread
|
||||
conversation={selectedConversation}
|
||||
startDate={startDate}
|
||||
@@ -380,7 +382,7 @@ function App() {
|
||||
</>
|
||||
) : activeView === 'search' ? (
|
||||
/* Search View */
|
||||
<div className="flex-fill bg-white rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||
<div className="flex-fill bg-body-secondary rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||
<Search
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
@@ -396,7 +398,7 @@ function App() {
|
||||
</div>
|
||||
) : activeView === 'calls' ? (
|
||||
/* Calls View */
|
||||
<div className="flex-fill bg-white rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||
<div className="flex-fill bg-body-secondary rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||
<Calls
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
@@ -404,7 +406,7 @@ function App() {
|
||||
</div>
|
||||
) : activeView === 'summary' ? (
|
||||
/* Summary View */
|
||||
<div className="flex-fill bg-white rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||
<div className="flex-fill bg-body-secondary rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||
<Summary
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
@@ -412,7 +414,7 @@ function App() {
|
||||
</div>
|
||||
) : (
|
||||
/* Activity View */
|
||||
<div className="flex-fill bg-white rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||
<div className="flex-fill bg-body-secondary rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||
<Activity
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
|
||||
@@ -266,7 +266,7 @@ function Activity({ startDate, endDate }) {
|
||||
|
||||
return (
|
||||
<div className="h-100 d-flex flex-column">
|
||||
<div className="bg-light border-bottom p-3">
|
||||
<div className="bg-body-tertiary border-bottom p-3">
|
||||
<h2 className="h5 mb-0 d-flex align-items-center gap-2">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
|
||||
@@ -200,7 +200,7 @@ function Calls({ startDate, endDate }) {
|
||||
|
||||
return (
|
||||
<div className="h-100 d-flex flex-column">
|
||||
<div className="bg-light border-bottom p-3">
|
||||
<div className="bg-body-tertiary border-bottom p-3">
|
||||
<h2 className="h5 mb-0 d-flex align-items-center gap-2">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
|
||||
@@ -133,7 +133,7 @@ function ConversationList({ conversations, selectedConversation, onSelectConvers
|
||||
style={{cursor: 'pointer'}}
|
||||
>
|
||||
<div className="d-flex align-items-start gap-2">
|
||||
<div className="flex-shrink-0 mt-1 p-2 rounded-circle bg-white shadow-sm">
|
||||
<div className="flex-shrink-0 mt-1 p-2 rounded-circle bg-body-tertiary shadow-sm">
|
||||
{getConversationIcon(conv.type)}
|
||||
</div>
|
||||
<div className="flex-fill min-w-0" style={{overflow: 'hidden'}}>
|
||||
|
||||
@@ -8,7 +8,7 @@ function DateFilter({ startDate, endDate, minDate, maxDate, onStartDateChange, o
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-2 py-1 bg-light">
|
||||
<div className="px-2 py-1 bg-body-tertiary">
|
||||
<div className="d-flex align-items-center gap-1 gap-sm-2 small">
|
||||
<svg style={{width: '1rem', height: '1rem'}} className="text-primary flex-shrink-0 d-none d-sm-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
|
||||
@@ -184,7 +184,7 @@ function LazyMedia({ messageId, mediaType, className, alt = "MMS attachment" })
|
||||
{/* Placeholder shown before loading or while loading */}
|
||||
{!src && !vcfData && !error && (
|
||||
<div
|
||||
className="bg-light rounded d-flex align-items-center justify-content-center position-relative overflow-hidden"
|
||||
className="bg-body-tertiary rounded d-flex align-items-center justify-content-center position-relative overflow-hidden"
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: isVideo ? '16/9' : isAudio ? 'auto' : '3/4', // Common phone camera ratio
|
||||
@@ -307,7 +307,7 @@ function LazyMedia({ messageId, mediaType, className, alt = "MMS attachment" })
|
||||
<VCardPreview vcfText={vcfData} messageId={messageId} />
|
||||
)}
|
||||
{!isImage && !isVideo && !isAudio && !isVCard && (
|
||||
<div className="small p-2 rounded bg-light d-flex align-items-center gap-1">
|
||||
<div className="small p-2 rounded bg-body-tertiary d-flex align-items-center gap-1">
|
||||
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
|
||||
function Login() {
|
||||
const [isLogin, setIsLogin] = useState(true)
|
||||
@@ -64,7 +65,10 @@ function Login() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-vh-100 d-flex align-items-center justify-content-center bg-light">
|
||||
<div className="min-vh-100 d-flex align-items-center justify-content-center bg-body-tertiary position-relative">
|
||||
<div className="position-absolute top-0 end-0 p-3" style={{ zIndex: 10 }}>
|
||||
<ThemeToggle variant="surface" />
|
||||
</div>
|
||||
<div className="container">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6 col-lg-4">
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Dark mode: bring the placeholder tile in line with Bootstrap's body-tertiary value. */
|
||||
html[data-bs-theme="dark"] .media-grid-item {
|
||||
background-color: #2c3034;
|
||||
}
|
||||
|
||||
.media-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { format } from 'date-fns'
|
||||
@@ -8,6 +9,8 @@ import MediaGrid from './MediaGrid'
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8085/api'
|
||||
|
||||
function MessageThread({ conversation, startDate, endDate, messageLimit }) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const location = useLocation()
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -509,12 +512,12 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
|
||||
|
||||
if (!conversation) {
|
||||
return (
|
||||
<div className="d-flex align-items-center justify-content-center h-100 text-muted">
|
||||
<div className="d-flex align-items-center justify-content-center h-100 text-body-secondary">
|
||||
<div className="text-center">
|
||||
<svg style={{width: '5rem', height: '5rem'}} className="mx-auto mb-3 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<p className="h5 text-dark">Select a conversation</p>
|
||||
<p className="h5 text-body-emphasis mb-2">Select a conversation</p>
|
||||
<p className="small mt-2">Choose a conversation from the list to view messages</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -558,7 +561,7 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
|
||||
)}
|
||||
|
||||
{/* Thread Header */}
|
||||
<div className="bg-light border-bottom p-2 p-md-4 shadow-sm">
|
||||
<div className="bg-body-tertiary border-bottom p-2 p-md-4 shadow-sm">
|
||||
<div className="d-flex align-items-center gap-2 gap-md-3">
|
||||
<div className="p-2 p-md-3 rounded-circle bg-primary bg-gradient shadow">
|
||||
{isCallLog ? (
|
||||
@@ -632,7 +635,7 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div ref={scrollContainerRef} className="flex-fill overflow-auto p-2 p-md-4 bg-light">
|
||||
<div ref={scrollContainerRef} className="flex-fill overflow-auto p-2 p-md-4 bg-body-secondary">
|
||||
{showMediaOnly && !isCallLog ? (
|
||||
// Media Grid View
|
||||
<MediaGrid conversation={conversation} startDate={startDate} endDate={endDate} />
|
||||
@@ -710,7 +713,7 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
|
||||
const typeInfo = getCallTypeInfo(call.type)
|
||||
return (
|
||||
<div key={`call-${call.id}`} className="d-flex justify-content-center my-1">
|
||||
<div className="badge bg-light text-dark border px-3 py-2 d-flex align-items-center gap-2" style={{fontSize: '0.75rem'}}>
|
||||
<div className="badge bg-body-secondary text-body-emphasis border px-3 py-2 d-flex align-items-center gap-2" style={{fontSize: '0.75rem'}}>
|
||||
<span className={typeInfo.color} style={{fontSize: '1rem'}}>{typeInfo.icon}</span>
|
||||
<span className={`fw-semibold ${typeInfo.color}`}>{typeInfo.label} call</span>
|
||||
<span className="text-muted">·</span>
|
||||
@@ -750,7 +753,9 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
|
||||
className={`card shadow-sm ${
|
||||
isSent
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-white'
|
||||
: isDark
|
||||
? 'bg-body-tertiary text-body'
|
||||
: 'bg-white'
|
||||
} ${
|
||||
isHighlighted
|
||||
? 'border-warning border-3'
|
||||
|
||||
@@ -77,7 +77,7 @@ function Search({ searchQuery, setSearchQuery, results, setResults, loading, set
|
||||
return (
|
||||
<div className="h-100 d-flex flex-column">
|
||||
{/* Header */}
|
||||
<div className="bg-light border-bottom p-3">
|
||||
<div className="bg-body-tertiary border-bottom p-3">
|
||||
<h2 className="h5 mb-3 d-flex align-items-center gap-2">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Summary charts — dark-mode surface + readable axis/tooltip text.
|
||||
*
|
||||
* Selectors are intentionally scoped to the Summary page only, using the
|
||||
* existing `.card` + `.border-*` stat cards and the plain `.card` chart
|
||||
* cards that are already in the DOM from Summary.jsx. No JSX changes are
|
||||
* required; this file is imported only by Summary.jsx.
|
||||
*/
|
||||
|
||||
/* Stats cards / chart cards: re-colorize the card body in dark mode. */
|
||||
html[data-bs-theme="dark"] .summary-view .card {
|
||||
background-color: var(--bs-body-tertiary);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .summary-view .card-body,
|
||||
html[data-bs-theme="dark"] .summary-view .border-primary .card-body,
|
||||
html[data-bs-theme="dark"] .summary-view .border-success .card-body,
|
||||
html[data-bs-theme="dark"] .summary-view .border-info .card-body,
|
||||
html[data-bs-theme="dark"] .summary-view .border-warning .card-body {
|
||||
background-color: var(--bs-body-secondary);
|
||||
}
|
||||
|
||||
/* Recharts SVG canvas */
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-wrapper {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
/* Axis lines and ticks */
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-cartesian-axis-line,
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-polar-axis-angle-axis line,
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-polar-axis-radial-axis line {
|
||||
stroke: var(--bs-border-color);
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-cartesian-axis-tick-value,
|
||||
html[data-bs-theme="dark"]
|
||||
.summary-view
|
||||
.recharts-polar-axis-angle-axis-tick-value,
|
||||
html[data-bs-theme="dark"]
|
||||
.summary-view
|
||||
.recharts-polar-axis-radial-axis-tick-value {
|
||||
fill: var(--bs-body-color);
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
html[data-bs-theme="dark"]
|
||||
.summary-view
|
||||
.recharts-cartesian-grid-horizontal
|
||||
line,
|
||||
html[data-bs-theme="dark"]
|
||||
.summary-view
|
||||
.recharts-cartesian-grid-vertical
|
||||
line {
|
||||
stroke: var(--bs-border-color);
|
||||
}
|
||||
|
||||
/* Tooltip popover */
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-tooltip-wrapper {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-default-tooltip {
|
||||
background-color: var(--bs-body-tertiary) !important;
|
||||
border-color: var(--bs-border-color) !important;
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
|
||||
/* Legend */
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-legend-item-text {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
/* Label text */
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-pie-label-text,
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-bar-label-text {
|
||||
fill: var(--bs-body-color);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
PieChart, Pie, Cell, LineChart, Line, Legend
|
||||
} from 'recharts'
|
||||
import './Summary.css'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8085/api'
|
||||
|
||||
@@ -114,8 +115,8 @@ function Summary({ startDate, endDate }) {
|
||||
const truncate = (str, max) => str.length > max ? str.slice(0, max) + '…' : str
|
||||
|
||||
return (
|
||||
<div className="h-100 d-flex flex-column">
|
||||
<div className="bg-light border-bottom p-3">
|
||||
<div className="h-100 d-flex flex-column summary-view">
|
||||
<div className="bg-body-tertiary border-bottom p-3">
|
||||
<h2 className="h5 mb-0 d-flex align-items-center gap-2">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
|
||||
const THEME_LABELS = {
|
||||
light: 'Light mode',
|
||||
dark: 'Dark mode',
|
||||
system: 'System theme',
|
||||
}
|
||||
|
||||
const NEXT_THEME_LABELS = {
|
||||
light: 'dark mode',
|
||||
dark: 'system theme',
|
||||
system: 'light mode',
|
||||
}
|
||||
|
||||
function ThemeToggle({ variant = 'header' }) {
|
||||
const { theme, resolvedTheme, toggle } = useTheme()
|
||||
const variantClass =
|
||||
variant === 'surface' ? 'theme-toggle-btn-surface' : 'theme-toggle-btn'
|
||||
|
||||
const title = `${THEME_LABELS[theme]}${theme === 'system' ? ` (${resolvedTheme})` : ''}. Switch to ${NEXT_THEME_LABELS[theme]}.`
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className={`btn btn-sm ${variantClass} p-1 d-inline-flex align-items-center justify-content-center`}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
|
||||
</svg>
|
||||
) : theme === 'light' ? (
|
||||
<svg
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeToggle
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
|
||||
const STORAGE_KEY = 'sbv-theme'
|
||||
const THEME_ORDER = ['light', 'dark', 'system']
|
||||
|
||||
const ThemeContext = createContext(null)
|
||||
|
||||
function getSystemTheme() {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
}
|
||||
return 'light'
|
||||
}
|
||||
|
||||
function resolveTheme(preference) {
|
||||
if (preference === 'system') {
|
||||
return getSystemTheme()
|
||||
}
|
||||
return preference
|
||||
}
|
||||
|
||||
function readStoredPreference() {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved === 'dark' || saved === 'light' || saved === 'system') {
|
||||
return saved
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable (e.g. private mode on some browsers)
|
||||
}
|
||||
return 'system'
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setTheme] = useState(readStoredPreference)
|
||||
const [resolvedTheme, setResolvedTheme] = useState(() =>
|
||||
resolveTheme(readStoredPreference()),
|
||||
)
|
||||
|
||||
const applyResolvedTheme = useCallback((resolved) => {
|
||||
setResolvedTheme(resolved)
|
||||
document.documentElement.setAttribute('data-bs-theme', resolved)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
applyResolvedTheme(resolveTheme(theme))
|
||||
}, [theme, applyResolvedTheme])
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== 'system' || !window.matchMedia) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const onChange = () => applyResolvedTheme(getSystemTheme())
|
||||
|
||||
mediaQuery.addEventListener('change', onChange)
|
||||
return () => mediaQuery.removeEventListener('change', onChange)
|
||||
}, [theme, applyResolvedTheme])
|
||||
|
||||
const toggle = () => {
|
||||
setTheme((current) => {
|
||||
const index = THEME_ORDER.indexOf(current)
|
||||
const next = THEME_ORDER[(index + 1) % THEME_ORDER.length]
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolvedTheme, toggle }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,8 +1,121 @@
|
||||
/* Theme toggle in primary header: high contrast on blue gradient */
|
||||
.theme-toggle-btn {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.22);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 0.375rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover,
|
||||
.theme-toggle-btn:focus-visible {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.35);
|
||||
border-color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
/* Theme toggle on body/card surfaces (e.g. login page) */
|
||||
.theme-toggle-btn-surface {
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.375rem;
|
||||
line-height: 1;
|
||||
box-shadow: var(--bs-box-shadow-sm);
|
||||
}
|
||||
|
||||
.theme-toggle-btn-surface:hover,
|
||||
.theme-toggle-btn-surface:focus-visible {
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-secondary-bg);
|
||||
border-color: var(--bs-border-color-translucent, var(--bs-border-color));
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
|
||||
/* Dark mode: ensure body inherits Bootstrap's dark custom properties. */
|
||||
html[data-bs-theme="dark"] body {
|
||||
background-color: #212529;
|
||||
color: #dee2e6;
|
||||
}
|
||||
|
||||
/* Dark mode: legacy utilities that force light-theme colors */
|
||||
html[data-bs-theme="dark"] .text-dark {
|
||||
color: var(--bs-body-emphasis-color) !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .bg-light.text-dark,
|
||||
html[data-bs-theme="dark"] .badge.bg-light {
|
||||
background-color: var(--bs-secondary-bg) !important;
|
||||
color: var(--bs-body-color) !important;
|
||||
border-color: var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
/* Dark mode: slightly brighter secondary copy on tertiary surfaces */
|
||||
html[data-bs-theme="dark"] .text-muted,
|
||||
html[data-bs-theme="dark"] .text-body-secondary {
|
||||
color: #adb5bd !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .text-body-emphasis {
|
||||
color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
/* Dark mode: react-datepicker popup and inputs */
|
||||
html[data-bs-theme="dark"] .react-datepicker {
|
||||
background-color: var(--bs-body-bg);
|
||||
border-color: var(--bs-border-color);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .react-datepicker__header {
|
||||
background-color: var(--bs-secondary-bg);
|
||||
border-bottom-color: var(--bs-border-color);
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .react-datepicker__current-month,
|
||||
html[data-bs-theme="dark"] .react-datepicker__day-name,
|
||||
html[data-bs-theme="dark"] .react-datepicker__day,
|
||||
html[data-bs-theme="dark"] .react-datepicker__time-name {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .react-datepicker__day:hover,
|
||||
html[data-bs-theme="dark"] .react-datepicker__day--keyboard-selected {
|
||||
background-color: var(--bs-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .react-datepicker__day--outside-month {
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .react-datepicker__navigation-icon::before {
|
||||
border-color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
/* Dark mode: subdued scrollbar for WebKit/Blink */
|
||||
html[data-bs-theme="dark"] ::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] ::-webkit-scrollbar-track {
|
||||
background: #1a1d20;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] ::-webkit-scrollbar-thumb {
|
||||
background: #495057;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] ::-webkit-scrollbar-thumb:hover {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
+18
-15
@@ -5,6 +5,7 @@ import axios from 'axios'
|
||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||
import './index.css'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import App from './App.jsx'
|
||||
import Login from './components/Login.jsx'
|
||||
import PrintView from './components/PrintView.jsx'
|
||||
@@ -16,21 +17,23 @@ axios.defaults.withCredentials = true
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/conversation/:address/print" element={
|
||||
<ProtectedRoute>
|
||||
<PrintView />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/*" element={
|
||||
<ProtectedRoute>
|
||||
<App />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/conversation/:address/print" element={
|
||||
<ProtectedRoute>
|
||||
<PrintView />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/*" element={
|
||||
<ProtectedRoute>
|
||||
<App />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user