diff --git a/frontend/src/App.css b/frontend/src/App.css index d5b78b9..3d4fd4e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2547e67..40a27d4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( -
+
{/* Header */}
@@ -201,6 +202,7 @@ function App() {

SMS Backup Viewer

+
{/* View Switcher */} -
+
  • @@ -319,7 +321,7 @@ function App() {
{/* Date Filter */} -
+
{/* Conversation List */}
-
+

@@ -369,7 +371,7 @@ function App() {

{/* Message Thread */} -
+
) : activeView === 'search' ? ( /* Search View */ -
+
) : activeView === 'calls' ? ( /* Calls View */ -
+
) : activeView === 'summary' ? ( /* Summary View */ -
+
) : ( /* Activity View */ -
+
-
+

diff --git a/frontend/src/components/Calls.jsx b/frontend/src/components/Calls.jsx index 609098a..74ecc8a 100644 --- a/frontend/src/components/Calls.jsx +++ b/frontend/src/components/Calls.jsx @@ -200,7 +200,7 @@ function Calls({ startDate, endDate }) { return (
-
+

diff --git a/frontend/src/components/ConversationList.jsx b/frontend/src/components/ConversationList.jsx index 06bf4c3..fa374ba 100644 --- a/frontend/src/components/ConversationList.jsx +++ b/frontend/src/components/ConversationList.jsx @@ -133,7 +133,7 @@ function ConversationList({ conversations, selectedConversation, onSelectConvers style={{cursor: 'pointer'}} >
-
+
{getConversationIcon(conv.type)}
diff --git a/frontend/src/components/DateFilter.jsx b/frontend/src/components/DateFilter.jsx index 0c4b61e..23b88a9 100644 --- a/frontend/src/components/DateFilter.jsx +++ b/frontend/src/components/DateFilter.jsx @@ -8,7 +8,7 @@ function DateFilter({ startDate, endDate, minDate, maxDate, onStartDateChange, o } return ( -
+
diff --git a/frontend/src/components/LazyMedia.jsx b/frontend/src/components/LazyMedia.jsx index 06a440e..00a0c30 100644 --- a/frontend/src/components/LazyMedia.jsx +++ b/frontend/src/components/LazyMedia.jsx @@ -184,7 +184,7 @@ function LazyMedia({ messageId, mediaType, className, alt = "MMS attachment" }) {/* Placeholder shown before loading or while loading */} {!src && !vcfData && !error && (
)} {!isImage && !isVideo && !isAudio && !isVCard && ( -
+
diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx index fc7d782..4b32df0 100644 --- a/frontend/src/components/Login.jsx +++ b/frontend/src/components/Login.jsx @@ -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 ( -
+
+
+ +
diff --git a/frontend/src/components/MediaGrid.css b/frontend/src/components/MediaGrid.css index c9ff28f..d7b5555 100644 --- a/frontend/src/components/MediaGrid.css +++ b/frontend/src/components/MediaGrid.css @@ -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%; diff --git a/frontend/src/components/MessageThread.jsx b/frontend/src/components/MessageThread.jsx index 7f96812..ff9147c 100644 --- a/frontend/src/components/MessageThread.jsx +++ b/frontend/src/components/MessageThread.jsx @@ -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 ( -
+
-

Select a conversation

+

Select a conversation

Choose a conversation from the list to view messages

@@ -558,7 +561,7 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) { )} {/* Thread Header */} -
+
{isCallLog ? ( @@ -632,7 +635,7 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
{/* Content */} -
+
{showMediaOnly && !isCallLog ? ( // Media Grid View @@ -710,7 +713,7 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) { const typeInfo = getCallTypeInfo(call.type) return (
-
+
{typeInfo.icon} {typeInfo.label} call · @@ -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' diff --git a/frontend/src/components/Search.jsx b/frontend/src/components/Search.jsx index 0a70748..049bbd8 100644 --- a/frontend/src/components/Search.jsx +++ b/frontend/src/components/Search.jsx @@ -77,7 +77,7 @@ function Search({ searchQuery, setSearchQuery, results, setResults, loading, set return (
{/* Header */} -
+

diff --git a/frontend/src/components/Summary.css b/frontend/src/components/Summary.css new file mode 100644 index 0000000..cf7cb8e --- /dev/null +++ b/frontend/src/components/Summary.css @@ -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); +} diff --git a/frontend/src/components/Summary.jsx b/frontend/src/components/Summary.jsx index 18d8c5a..26fc37f 100644 --- a/frontend/src/components/Summary.jsx +++ b/frontend/src/components/Summary.jsx @@ -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 ( -
-
+
+

diff --git a/frontend/src/components/ThemeToggle.jsx b/frontend/src/components/ThemeToggle.jsx new file mode 100644 index 0000000..fa535d8 --- /dev/null +++ b/frontend/src/components/ThemeToggle.jsx @@ -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 ( + + ) +} + +export default ThemeToggle diff --git a/frontend/src/contexts/ThemeContext.jsx b/frontend/src/contexts/ThemeContext.jsx new file mode 100644 index 0000000..7e80b82 --- /dev/null +++ b/frontend/src/contexts/ThemeContext.jsx @@ -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 ( + + {children} + + ) +} + +export function useTheme() { + const context = useContext(ThemeContext) + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider') + } + return context +} diff --git a/frontend/src/index.css b/frontend/src/index.css index b0ea31a..e69cd74 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index dca4301..2f82cef 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -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( - - - } /> - - - - } /> - - - - } /> - - + + + + } /> + + + + } /> + + + + } /> + + + , )