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:
ryang3d
2026-06-04 00:01:03 -07:00
co-authored by Cursor
parent 47626f0c7c
commit 40bd028efe
17 changed files with 422 additions and 42 deletions
+2 -1
View File
@@ -224,7 +224,8 @@
} }
/* Call log items */ /* Call log items */
.bg-light.text-dark.border { .bg-light.text-dark.border,
.bg-body-secondary.text-body-emphasis.border {
background: white !important; background: white !important;
border: 1px solid #666 !important; border: 1px solid #666 !important;
color: black !important; color: black !important;
+12 -10
View File
@@ -13,6 +13,7 @@ import Search from './components/Search'
import Summary from './components/Summary' import Summary from './components/Summary'
import ChangePasswordModal from './components/ChangePasswordModal' import ChangePasswordModal from './components/ChangePasswordModal'
import SettingsModal from './components/SettingsModal' import SettingsModal from './components/SettingsModal'
import ThemeToggle from './components/ThemeToggle'
import './App.css' import './App.css'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8085/api' const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8085/api'
@@ -190,7 +191,7 @@ function App() {
}) })
return ( return (
<div className="vh-100 d-flex flex-column bg-light"> <div className="vh-100 d-flex flex-column bg-body-tertiary">
{/* Header */} {/* Header */}
<header className="bg-primary bg-gradient text-white py-1 px-2 shadow" style={{zIndex: 1030}}> <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"> <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> <h1 className="h5 mb-0 fw-bold">SMS Backup Viewer</h1>
</div> </div>
<div className="d-flex align-items-center gap-2"> <div className="d-flex align-items-center gap-2">
<ThemeToggle />
<button <button
onClick={() => setShowUpload(true)} onClick={() => setShowUpload(true)}
className="btn btn-light btn-sm shadow-sm d-flex align-items-center gap-1" className="btn btn-light btn-sm shadow-sm d-flex align-items-center gap-1"
@@ -256,7 +258,7 @@ function App() {
</header> </header>
{/* View Switcher */} {/* View Switcher */}
<div className="bg-white border-bottom shadow-sm"> <div className="bg-body-tertiary border-bottom shadow-sm">
<div className="container-fluid"> <div className="container-fluid">
<ul className="nav nav-tabs border-0"> <ul className="nav nav-tabs border-0">
<li className="nav-item"> <li className="nav-item">
@@ -319,7 +321,7 @@ function App() {
</div> </div>
{/* Date Filter */} {/* 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 <DateFilter
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
@@ -336,9 +338,9 @@ function App() {
<> <>
{/* Conversation List */} {/* Conversation List */}
<div <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"> <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"> <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" /> <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> </div>
{/* Message Thread */} {/* 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 <MessageThread
conversation={selectedConversation} conversation={selectedConversation}
startDate={startDate} startDate={startDate}
@@ -380,7 +382,7 @@ function App() {
</> </>
) : activeView === 'search' ? ( ) : activeView === 'search' ? (
/* Search View */ /* 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 <Search
searchQuery={searchQuery} searchQuery={searchQuery}
setSearchQuery={setSearchQuery} setSearchQuery={setSearchQuery}
@@ -396,7 +398,7 @@ function App() {
</div> </div>
) : activeView === 'calls' ? ( ) : activeView === 'calls' ? (
/* Calls View */ /* 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 <Calls
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
@@ -404,7 +406,7 @@ function App() {
</div> </div>
) : activeView === 'summary' ? ( ) : activeView === 'summary' ? (
/* Summary View */ /* 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 <Summary
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
@@ -412,7 +414,7 @@ function App() {
</div> </div>
) : ( ) : (
/* Activity View */ /* 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 <Activity
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
+1 -1
View File
@@ -266,7 +266,7 @@ function Activity({ startDate, endDate }) {
return ( return (
<div className="h-100 d-flex flex-column"> <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"> <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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
+1 -1
View File
@@ -200,7 +200,7 @@ function Calls({ startDate, endDate }) {
return ( return (
<div className="h-100 d-flex flex-column"> <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"> <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"> <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" /> <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" />
+1 -1
View File
@@ -133,7 +133,7 @@ function ConversationList({ conversations, selectedConversation, onSelectConvers
style={{cursor: 'pointer'}} style={{cursor: 'pointer'}}
> >
<div className="d-flex align-items-start gap-2"> <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)} {getConversationIcon(conv.type)}
</div> </div>
<div className="flex-fill min-w-0" style={{overflow: 'hidden'}}> <div className="flex-fill min-w-0" style={{overflow: 'hidden'}}>
+1 -1
View File
@@ -8,7 +8,7 @@ function DateFilter({ startDate, endDate, minDate, maxDate, onStartDateChange, o
} }
return ( 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"> <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"> <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" /> <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" />
+2 -2
View File
@@ -184,7 +184,7 @@ function LazyMedia({ messageId, mediaType, className, alt = "MMS attachment" })
{/* Placeholder shown before loading or while loading */} {/* Placeholder shown before loading or while loading */}
{!src && !vcfData && !error && ( {!src && !vcfData && !error && (
<div <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={{ style={{
width: '100%', width: '100%',
aspectRatio: isVideo ? '16/9' : isAudio ? 'auto' : '3/4', // Common phone camera ratio 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} /> <VCardPreview vcfText={vcfData} messageId={messageId} />
)} )}
{!isImage && !isVideo && !isAudio && !isVCard && ( {!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"> <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" /> <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> </svg>
+5 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import ThemeToggle from './ThemeToggle'
function Login() { function Login() {
const [isLogin, setIsLogin] = useState(true) const [isLogin, setIsLogin] = useState(true)
@@ -64,7 +65,10 @@ function Login() {
} }
return ( 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="container">
<div className="row justify-content-center"> <div className="row justify-content-center">
<div className="col-md-6 col-lg-4"> <div className="col-md-6 col-lg-4">
+5
View File
@@ -22,6 +22,11 @@
z-index: 1; 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 { .media-thumbnail {
width: 100%; width: 100%;
height: 100%; height: 100%;
+11 -6
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useLayoutEffect, useRef } from 'react' import { useState, useEffect, useLayoutEffect, useRef } from 'react'
import { useTheme } from '../contexts/ThemeContext'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import { format } from 'date-fns' 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' const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8085/api'
function MessageThread({ conversation, startDate, endDate, messageLimit }) { function MessageThread({ conversation, startDate, endDate, messageLimit }) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const location = useLocation() const location = useLocation()
const [items, setItems] = useState([]) const [items, setItems] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -509,12 +512,12 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
if (!conversation) { if (!conversation) {
return ( 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"> <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"> <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" /> <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> </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> <p className="small mt-2">Choose a conversation from the list to view messages</p>
</div> </div>
</div> </div>
@@ -558,7 +561,7 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
)} )}
{/* Thread Header */} {/* 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="d-flex align-items-center gap-2 gap-md-3">
<div className="p-2 p-md-3 rounded-circle bg-primary bg-gradient shadow"> <div className="p-2 p-md-3 rounded-circle bg-primary bg-gradient shadow">
{isCallLog ? ( {isCallLog ? (
@@ -632,7 +635,7 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
</div> </div>
{/* Content */} {/* 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 ? ( {showMediaOnly && !isCallLog ? (
// Media Grid View // Media Grid View
<MediaGrid conversation={conversation} startDate={startDate} endDate={endDate} /> <MediaGrid conversation={conversation} startDate={startDate} endDate={endDate} />
@@ -710,7 +713,7 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
const typeInfo = getCallTypeInfo(call.type) const typeInfo = getCallTypeInfo(call.type)
return ( return (
<div key={`call-${call.id}`} className="d-flex justify-content-center my-1"> <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={typeInfo.color} style={{fontSize: '1rem'}}>{typeInfo.icon}</span>
<span className={`fw-semibold ${typeInfo.color}`}>{typeInfo.label} call</span> <span className={`fw-semibold ${typeInfo.color}`}>{typeInfo.label} call</span>
<span className="text-muted">·</span> <span className="text-muted">·</span>
@@ -750,7 +753,9 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
className={`card shadow-sm ${ className={`card shadow-sm ${
isSent isSent
? 'bg-primary text-white' ? 'bg-primary text-white'
: 'bg-white' : isDark
? 'bg-body-tertiary text-body'
: 'bg-white'
} ${ } ${
isHighlighted isHighlighted
? 'border-warning border-3' ? 'border-warning border-3'
+1 -1
View File
@@ -77,7 +77,7 @@ function Search({ searchQuery, setSearchQuery, results, setResults, loading, set
return ( return (
<div className="h-100 d-flex flex-column"> <div className="h-100 d-flex flex-column">
{/* Header */} {/* 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"> <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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
+78
View File
@@ -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);
}
+3 -2
View File
@@ -4,6 +4,7 @@ import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell, LineChart, Line, Legend PieChart, Pie, Cell, LineChart, Line, Legend
} from 'recharts' } from 'recharts'
import './Summary.css'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8085/api' 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 const truncate = (str, max) => str.length > max ? str.slice(0, max) + '…' : str
return ( return (
<div className="h-100 d-flex flex-column"> <div className="h-100 d-flex flex-column summary-view">
<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"> <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"> <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" /> <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" />
+79
View File
@@ -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
+89
View File
@@ -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
}
+113
View File
@@ -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 { body {
margin: 0; margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; 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 { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
+18 -15
View File
@@ -5,6 +5,7 @@ import axios from 'axios'
import 'bootstrap/dist/css/bootstrap.min.css' import 'bootstrap/dist/css/bootstrap.min.css'
import './index.css' import './index.css'
import { AuthProvider } from './contexts/AuthContext' import { AuthProvider } from './contexts/AuthContext'
import { ThemeProvider } from './contexts/ThemeContext'
import App from './App.jsx' import App from './App.jsx'
import Login from './components/Login.jsx' import Login from './components/Login.jsx'
import PrintView from './components/PrintView.jsx' import PrintView from './components/PrintView.jsx'
@@ -16,21 +17,23 @@ axios.defaults.withCredentials = true
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
<AuthProvider> <ThemeProvider>
<Routes> <AuthProvider>
<Route path="/login" element={<Login />} /> <Routes>
<Route path="/conversation/:address/print" element={ <Route path="/login" element={<Login />} />
<ProtectedRoute> <Route path="/conversation/:address/print" element={
<PrintView /> <ProtectedRoute>
</ProtectedRoute> <PrintView />
} /> </ProtectedRoute>
<Route path="/*" element={ } />
<ProtectedRoute> <Route path="/*" element={
<App /> <ProtectedRoute>
</ProtectedRoute> <App />
} /> </ProtectedRoute>
</Routes> } />
</AuthProvider> </Routes>
</AuthProvider>
</ThemeProvider>
</BrowserRouter> </BrowserRouter>
</StrictMode>, </StrictMode>,
) )