Compare commits

...
10 Commits
Author SHA1 Message Date
ryang3dandCursor 2aac960298 Document light, dark, and system theme toggle in README
Docker Build and Push / prepare (push) Canceled after 0s
Docker Build and Push / build (linux/amd64, ubuntu-latest) (push) Canceled after 0s
Docker Build and Push / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Docker Build and Push / merge (push) Canceled after 0s
Co-authored-by: Cursor <[email protected]>
2026-06-04 00:01:03 -07:00
ryang3dandCursor 40bd028efe 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]>
2026-06-04 00:01:03 -07:00
lowcarbdev 47626f0c7c optimize sql by removing unused field, add indexes 2026-05-01 18:33:29 -06:00
lowcarbdev 3fef1925d6 load more messages 2026-04-30 23:43:28 -06:00
lowcarbdev 250b9030ea setting to limit number of messages shown 2026-04-29 23:11:50 -06:00
lowcarbdev 32d6110733 fix deprecations 2026-03-29 15:33:15 -06:00
lowcarbdev f839996050 fix inspect job 2026-03-29 15:20:29 -06:00
lowcarbdev e9d695d7c1 native arm64 runner 2026-03-29 15:15:44 -06:00
lowcarbdev 4397232f06 don't fail if uid/gid exists 2026-03-28 22:37:56 -06:00
lowcarbdev f89b214e4d update README 2026-03-04 20:54:41 -07:00
25 changed files with 824 additions and 130 deletions
+101 -29
View File
@@ -16,32 +16,19 @@ env:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
build-and-push: prepare:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: outputs:
contents: read version: ${{ steps.version.outputs.version }}
packages: write tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
@@ -61,25 +48,110 @@ jobs:
id: version id: version
run: | run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
# For tags, strip the 'v' prefix
VERSION="${{ github.ref_name }}" VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}" VERSION="${VERSION#v}"
else else
# For non-tags, use git commit hash with dirty flag
VERSION=$(git describe --always --dirty) VERSION=$(git describe --always --dirty)
fi fi
echo "version=git-${VERSION}" >> $GITHUB_OUTPUT echo "version=git-${VERSION}" >> $GITHUB_OUTPUT
echo "Building with version: git-${VERSION}" echo "Building with version: git-${VERSION}"
- name: Build and push Docker image build:
uses: docker/build-push-action@v5 needs: prepare
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image digest
id: build
uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: ${{ matrix.platform }}
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} labels: ${{ needs.prepare.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
build-args: | build-args: |
VERSION=${{ steps.version.outputs.version }} VERSION=${{ needs.prepare.outputs.version }}
cache-from: type=gha cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
- name: Export digest
if: github.event_name != 'pull_request'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.runner }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
needs: [prepare, build]
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push manifest
run: |
TAGS=$(echo "${{ needs.prepare.outputs.tags }}" | tr '\n' ' ')
TAG_ARGS=""
for tag in $TAGS; do
TAG_ARGS="$TAG_ARGS -t $tag"
done
docker buildx imagetools create $TAG_ARGS \
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
working-directory: /tmp/digests
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
continue-on-error: true
+2 -1
View File
@@ -49,6 +49,7 @@ services:
- **Summary view** - View analytics about your messages - **Summary view** - View analytics about your messages
- **vCard preview** - Preview the contents of contact cards (vCards) - **vCard preview** - Preview the contents of contact cards (vCards)
- **Mobile view** - UI works on both desktop and mobile - **Mobile view** - UI works on both desktop and mobile
- **Light, dark, and system themes** - Use the theme toggle in the header to switch between light mode, dark mode, or follow your OS preference. The UI (conversations, search, activity, and settings) uses Bootstrap theming so surfaces and text stay readable in either mode.
## Tech Stack ## Tech Stack
@@ -97,7 +98,7 @@ XML backups from the [SMS Backup & Restore app](https://play.google.com/store/ap
Q: What media and attachments can be previewed? Q: What media and attachments can be previewed?
SBV supports most images formats (jpg, png, gif, heic), video formats (mp4, 3gp), audio (mp4). Contact cards (aka vCard or VCF) are supported. SBV supports most images formats (jpg, png, gif, heic), video formats (mp4, 3gp), audio (mp4, amr). Contact cards (aka vCard or VCF) are supported.
Q: Why do I only see calls or messages, but not both? Q: Why do I only see calls or messages, but not both?
+10 -16
View File
@@ -5,30 +5,24 @@ set -e
PUID="${PUID:-1000}" PUID="${PUID:-1000}"
PGID="${PGID:-1000}" PGID="${PGID:-1000}"
# Create group if it doesn't exist # Use existing group if the GID is already taken, otherwise create one
if ! getent group sbv >/dev/null 2>&1; then if ! getent group "${PGID}" >/dev/null 2>&1; then
addgroup -g "${PGID}" sbv addgroup -g "${PGID}" sbv
fi fi
SBV_GROUP="$(getent group "${PGID}" | cut -d: -f1)"
# Create user if it doesn't exist # Use existing user if the UID is already taken, otherwise create one
if ! getent passwd sbv >/dev/null 2>&1; then if ! getent passwd "${PUID}" >/dev/null 2>&1; then
adduser -D -u "${PUID}" -G sbv sbv adduser -D -u "${PUID}" -G "${SBV_GROUP}" sbv
fi
# Ensure the user has the correct UID/GID
if [ "$(id -u sbv)" != "${PUID}" ] || [ "$(id -g sbv)" != "${PGID}" ]; then
deluser sbv >/dev/null 2>&1 || true
delgroup sbv >/dev/null 2>&1 || true
addgroup -g "${PGID}" sbv
adduser -D -u "${PUID}" -G sbv sbv
fi fi
SBV_USER="$(getent passwd "${PUID}" | cut -d: -f1)"
# Ensure data directory exists and has correct permissions # Ensure data directory exists and has correct permissions
mkdir -p "${DB_PATH_PREFIX:-/data}" mkdir -p "${DB_PATH_PREFIX:-/data}"
chown -R sbv:sbv "${DB_PATH_PREFIX:-/data}" chown -R "${SBV_USER}:${SBV_GROUP}" "${DB_PATH_PREFIX:-/data}"
# Log the user we're running as # Log the user we're running as
echo "Running as UID=${PUID} GID=${PGID}" echo "Running as UID=${PUID} GID=${PGID} (${SBV_USER}:${SBV_GROUP})"
# Switch to the sbv user and execute the application # Switch to the sbv user and execute the application
exec su-exec sbv "$@" exec su-exec "${SBV_USER}" "$@"
+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;
+17 -12
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'
@@ -34,7 +35,8 @@ function App() {
const [version, setVersion] = useState('...') const [version, setVersion] = useState('...')
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
conversations: { conversations: {
show_calls: true show_calls: true,
message_limit: 100000
} }
}) })
@@ -89,7 +91,8 @@ function App() {
// Use default settings if fetch fails // Use default settings if fetch fails
setSettings({ setSettings({
conversations: { conversations: {
show_calls: true show_calls: true,
message_limit: 100000
} }
}) })
} }
@@ -188,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">
@@ -199,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"
@@ -254,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">
@@ -317,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}
@@ -334,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" />
@@ -367,17 +371,18 @@ 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}
endDate={endDate} endDate={endDate}
messageLimit={settings.conversations.message_limit}
/> />
</div> </div>
</> </>
) : 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}
@@ -393,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}
@@ -401,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}
@@ -409,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%;
+197 -16
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, 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'
@@ -7,24 +8,53 @@ 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 }) { 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)
const [loadingOlder, setLoadingOlder] = useState(false)
const [loadingNewer, setLoadingNewer] = useState(false)
const [offset, setOffset] = useState(0)
const [tailOffset, setTailOffset] = useState(0)
const [totalCount, setTotalCount] = useState(0)
const [highlightedMessageId, setHighlightedMessageId] = useState(null) const [highlightedMessageId, setHighlightedMessageId] = useState(null)
const [isPreprintingMedia, setIsPreprintingMedia] = useState(false) const [isPreprintingMedia, setIsPreprintingMedia] = useState(false)
const [showMediaOnly, setShowMediaOnly] = useState(false) const [showMediaOnly, setShowMediaOnly] = useState(false)
const messageRefs = useRef({}) const messageRefs = useRef({})
const printTriggeredRef = useRef(false) const printTriggeredRef = useRef(false)
const scrollContainerRef = useRef(null)
const suppressAutoScrollRef = useRef(false)
const scrollToItemIdRef = useRef(null)
useEffect(() => { useEffect(() => {
if (conversation) { if (conversation) {
setOffset(0)
setTailOffset(0)
setTotalCount(0)
setItems([])
fetchItems() fetchItems()
setShowMediaOnly(false) // Reset to message view when conversation changes setShowMediaOnly(false)
} else { } else {
setItems([]) setItems([])
setOffset(0)
setTailOffset(0)
setTotalCount(0)
} }
}, [conversation, startDate, endDate]) }, [conversation, startDate, endDate, messageLimit])
// After loading older items, scroll to the first new item so the user sees
// where the new content starts.
useLayoutEffect(() => {
if (scrollToItemIdRef.current !== null) {
const el = messageRefs.current[scrollToItemIdRef.current]
if (el) {
el.scrollIntoView({ block: 'start' })
}
scrollToItemIdRef.current = null
}
})
// Scroll to specific message if messageId is in URL // Scroll to specific message if messageId is in URL
useEffect(() => { useEffect(() => {
@@ -104,6 +134,11 @@ function MessageThread({ conversation, startDate, endDate }) {
// Automatically scroll to the last message when opening a conversation // Automatically scroll to the last message when opening a conversation
useEffect(() => { useEffect(() => {
if (suppressAutoScrollRef.current) {
suppressAutoScrollRef.current = false
return
}
if (items.length > 0) { if (items.length > 0) {
const params = new URLSearchParams(location.search) const params = new URLSearchParams(location.search)
const messageId = params.get('messageId') const messageId = params.get('messageId')
@@ -229,19 +264,119 @@ function MessageThread({ conversation, startDate, endDate }) {
const fetchItems = async () => { const fetchItems = async () => {
setLoading(true) setLoading(true)
try { try {
const limit = messageLimit || 100000
const params = { const params = {
address: conversation.address, address: conversation.address,
type: conversation.type type: conversation.type,
limit,
offset: 0,
}
if (startDate) params.start = startDate.toISOString()
if (endDate) params.end = endDate.toISOString()
// Fetch with offset 0 first to get the total count, then re-fetch the last page
const probe = await axios.get(`${API_BASE}/messages`, { params })
const total = probe.data.total || 0
setTotalCount(total)
// If all messages fit in one page, we're done
if (total <= limit) {
setOffset(0)
setItems(probe.data.items || [])
return
}
// Otherwise fetch the last page so the most recent messages are shown
const lastPageOffset = Math.max(0, total - limit)
const lastPageParams = { ...params, offset: lastPageOffset }
const response = await axios.get(`${API_BASE}/messages`, { params: lastPageParams })
setOffset(lastPageOffset)
setItems(response.data.items || [])
} catch (error) {
console.error('Error fetching items:', error)
} finally {
setLoading(false)
}
}
const fetchOlderItems = async () => {
const limit = messageLimit || 100000
const newOffset = Math.max(0, offset - limit)
// How many rows to fetch: exactly the gap between newOffset and current offset
const fetchLimit = offset - newOffset
setLoadingOlder(true)
try {
const params = {
address: conversation.address,
type: conversation.type,
limit: fetchLimit,
offset: newOffset,
} }
if (startDate) params.start = startDate.toISOString() if (startDate) params.start = startDate.toISOString()
if (endDate) params.end = endDate.toISOString() if (endDate) params.end = endDate.toISOString()
const response = await axios.get(`${API_BASE}/messages`, { params }) const response = await axios.get(`${API_BASE}/messages`, { params })
setItems(response.data || []) const olderItems = response.data.items || []
// Record the last new item's id so the layout effect can scroll to it —
// user lands at the newest of the older messages and can scroll up from there
const lastNewItem = olderItems[olderItems.length - 1]
if (lastNewItem) {
const msg = lastNewItem.type === 'message' ? lastNewItem.message : lastNewItem.call
scrollToItemIdRef.current = msg?.id ?? lastNewItem.id
}
suppressAutoScrollRef.current = true
const trimCount = olderItems.length
setItems(prev => {
const combined = [...olderItems, ...prev]
return (trimCount > 0 && combined.length > trimCount)
? combined.slice(0, combined.length - trimCount)
: combined
})
setOffset(newOffset)
setTailOffset(to => to + trimCount)
} catch (error) { } catch (error) {
console.error('Error fetching items:', error) console.error('Error fetching older items:', error)
} finally { } finally {
setLoading(false) setLoadingOlder(false)
}
}
const fetchNewerItems = async () => {
const limit = messageLimit || 100000
// tailOffset is the DB offset of the first trimmed row; fetch up to limit rows from there
const fetchLimit = Math.min(limit, tailOffset)
const fetchOffset = tailOffset - fetchLimit
setLoadingNewer(true)
try {
const params = {
address: conversation.address,
type: conversation.type,
limit: fetchLimit,
offset: fetchOffset,
}
if (startDate) params.start = startDate.toISOString()
if (endDate) params.end = endDate.toISOString()
const response = await axios.get(`${API_BASE}/messages`, { params })
const newerItems = response.data.items || []
setTailOffset(fetchOffset)
setItems(prev => {
const combined = [...prev, ...newerItems]
// Trim the same number of rows from the head to keep memory bounded
const trimCount = newerItems.length
if (trimCount > 0 && combined.length > trimCount) {
setOffset(o => o + trimCount)
return combined.slice(trimCount)
}
return combined
})
} catch (error) {
console.error('Error fetching newer items:', error)
} finally {
setLoadingNewer(false)
} }
} }
@@ -377,12 +512,12 @@ function MessageThread({ conversation, startDate, endDate }) {
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>
@@ -426,7 +561,7 @@ function MessageThread({ conversation, startDate, endDate }) {
)} )}
{/* 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 ? (
@@ -463,7 +598,14 @@ function MessageThread({ conversation, startDate, endDate }) {
})()} })()}
<div className="d-flex align-items-center gap-2"> <div className="d-flex align-items-center gap-2">
<span className="badge bg-primary" style={{fontSize: '0.7rem'}}> <span className="badge bg-primary" style={{fontSize: '0.7rem'}}>
{items.length} {isCallLog ? 'call' : 'message'}{items.length !== 1 ? 's' : ''} {totalCount > items.length
? (() => {
const end = totalCount - tailOffset
const start = end - items.length + 1
return `${isCallLog ? 'call' : 'message'}s ${start}${end} of ${totalCount}`
})()
: `${items.length} ${isCallLog ? 'call' : 'message'}${items.length !== 1 ? 's' : ''}`
}
</span> </span>
</div> </div>
</div> </div>
@@ -493,7 +635,7 @@ function MessageThread({ conversation, startDate, endDate }) {
</div> </div>
{/* Content */} {/* Content */}
<div 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} />
@@ -541,8 +683,26 @@ function MessageThread({ conversation, startDate, endDate }) {
) : ( ) : (
// Unified Message and Call View // Unified Message and Call View
<div className="d-flex flex-column gap-1"> <div className="d-flex flex-column gap-1">
{/* Load older messages button */}
{offset > 0 && (
<div className="d-flex justify-content-center mb-2">
<button
className="btn btn-sm btn-outline-secondary"
onClick={fetchOlderItems}
disabled={loadingOlder}
>
{loadingOlder ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Loading...
</>
) : (
`↑ Load older messages`
)}
</button>
</div>
)}
{items.map((item) => { {items.map((item) => {
// Check if this is an ActivityItem (has type field) or a direct Message
const isActivityItem = item.type === 'message' || item.type === 'call' const isActivityItem = item.type === 'message' || item.type === 'call'
const isCall = isActivityItem && item.type === 'call' const isCall = isActivityItem && item.type === 'call'
const message = isActivityItem ? item.message : item const message = isActivityItem ? item.message : item
@@ -553,7 +713,7 @@ function MessageThread({ conversation, startDate, endDate }) {
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>
@@ -593,7 +753,9 @@ function MessageThread({ conversation, startDate, endDate }) {
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'
@@ -635,6 +797,25 @@ function MessageThread({ conversation, startDate, endDate }) {
</div> </div>
) )
})} })}
{/* Load newer messages button */}
{tailOffset > 0 && (
<div className="d-flex justify-content-center mt-2">
<button
className="btn btn-sm btn-outline-secondary"
onClick={fetchNewerItems}
disabled={loadingNewer}
>
{loadingNewer ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Loading...
</>
) : (
`↓ Load newer messages`
)}
</button>
</div>
)}
</div> </div>
)} )}
</div> </div>
+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" />
+40 -3
View File
@@ -3,10 +3,20 @@ import axios from 'axios'
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'
const MESSAGE_LIMIT_OPTIONS = [
{ value: 100, label: '100' },
{ value: 1000, label: '1,000' },
{ value: 10000, label: '10,000' },
{ value: 100000, label: '100,000' },
{ value: 200000, label: '200,000' },
{ value: 500000, label: '500,000' },
]
function SettingsModal({ show, onClose, onSettingsUpdated }) { function SettingsModal({ show, onClose, onSettingsUpdated }) {
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
conversations: { conversations: {
show_calls: true show_calls: true,
message_limit: 100000
} }
}) })
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -38,7 +48,6 @@ function SettingsModal({ show, onClose, onSettingsUpdated }) {
setError('') setError('')
await axios.put(`${API_BASE}/settings`, settings) await axios.put(`${API_BASE}/settings`, settings)
// Notify parent component that settings were updated
if (onSettingsUpdated) { if (onSettingsUpdated) {
onSettingsUpdated(settings) onSettingsUpdated(settings)
} }
@@ -62,6 +71,16 @@ function SettingsModal({ show, onClose, onSettingsUpdated }) {
}) })
} }
const handleMessageLimitChange = (e) => {
setSettings({
...settings,
conversations: {
...settings.conversations,
message_limit: parseInt(e.target.value, 10)
}
})
}
if (!show) return null if (!show) return null
return ( return (
@@ -89,7 +108,7 @@ function SettingsModal({ show, onClose, onSettingsUpdated }) {
<h6 className="mb-3">Conversations</h6> <h6 className="mb-3">Conversations</h6>
<div className="form-check form-switch"> <div className="form-check form-switch mb-3">
<input <input
className="form-check-input" className="form-check-input"
type="checkbox" type="checkbox"
@@ -105,6 +124,24 @@ function SettingsModal({ show, onClose, onSettingsUpdated }) {
When enabled, phone calls will appear in the conversation list alongside messages. When enabled, phone calls will appear in the conversation list alongside messages.
</div> </div>
</div> </div>
<div className="mb-3">
<label htmlFor="messageLimitSelect" className="form-label">Messages to load</label>
<select
className="form-select"
id="messageLimitSelect"
value={settings.conversations.message_limit || 100000}
onChange={handleMessageLimitChange}
disabled={saving}
>
{MESSAGE_LIMIT_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<div className="form-text">
Maximum number of messages shown when opening a conversation.
</div>
</div>
</> </>
)} )}
</div> </div>
+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>,
) )
+26 -9
View File
@@ -114,6 +114,7 @@ func InitDB(filepath string) error {
CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id); CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id);
CREATE INDEX IF NOT EXISTS idx_record_type ON messages(record_type); CREATE INDEX IF NOT EXISTS idx_record_type ON messages(record_type);
CREATE INDEX IF NOT EXISTS idx_record_type_date ON messages(record_type, date); CREATE INDEX IF NOT EXISTS idx_record_type_date ON messages(record_type, date);
CREATE INDEX IF NOT EXISTS idx_address_date ON messages(address, date);
-- Create unique constraints for idempotent imports -- Create unique constraints for idempotent imports
-- record_type differentiates SMS (1), MMS (2), and calls (3) -- record_type differentiates SMS (1), MMS (2), and calls (3)
@@ -222,6 +223,7 @@ func InitUserDB(userID string, filepath string) error {
CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id); CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id);
CREATE INDEX IF NOT EXISTS idx_record_type ON messages(record_type); CREATE INDEX IF NOT EXISTS idx_record_type ON messages(record_type);
CREATE INDEX IF NOT EXISTS idx_record_type_date ON messages(record_type, date); CREATE INDEX IF NOT EXISTS idx_record_type_date ON messages(record_type, date);
CREATE INDEX IF NOT EXISTS idx_address_date ON messages(address, date);
-- record_type differentiates SMS (1), MMS (2), and calls (3) -- record_type differentiates SMS (1), MMS (2), and calls (3)
CREATE UNIQUE INDEX IF NOT EXISTS idx_message_unique ON messages(record_type, address, date, type, COALESCE(body, ''), COALESCE(content_type, ''), COALESCE(message_id, ''), COALESCE(duration, 0)); CREATE UNIQUE INDEX IF NOT EXISTS idx_message_unique ON messages(record_type, address, date, type, COALESCE(body, ''), COALESCE(content_type, ''), COALESCE(message_id, ''), COALESCE(duration, 0));
@@ -690,11 +692,11 @@ func GetActivity(userDB *sql.DB, startDate, endDate *time.Time, limit, offset in
func GetActivityByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time, limit, offset int) ([]ActivityItem, error) { func GetActivityByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time, limit, offset int) ([]ActivityItem, error) {
var activities []ActivityItem var activities []ActivityItem
// Query from unified table // Query from unified table — media_data is intentionally excluded; fetched on-demand via /api/media
query := ` query := `
SELECT record_type, date, address, COALESCE(contact_name, '') as contact_name, SELECT record_type, date, address, COALESCE(contact_name, '') as contact_name,
id, body, type, read, thread_id, COALESCE(subject, ''), id, body, type, read, thread_id, COALESCE(subject, ''),
COALESCE(media_type, ''), COALESCE(media_data, ''), COALESCE(media_type, ''),
COALESCE(protocol, 0), COALESCE(status, 0), COALESCE(service_center, ''), COALESCE(protocol, 0), COALESCE(status, 0), COALESCE(service_center, ''),
COALESCE(sub_id, 0), COALESCE(content_type, ''), COALESCE(read_report, 0), COALESCE(sub_id, 0), COALESCE(content_type, ''), COALESCE(read_report, 0),
COALESCE(read_status, 0), COALESCE(message_id, ''), COALESCE(message_size, 0), COALESCE(read_status, 0), COALESCE(message_id, ''), COALESCE(message_size, 0),
@@ -745,14 +747,13 @@ func GetActivityByAddress(userDB *sql.DB, address string, startDate, endDate *ti
// Message fields // Message fields
var body, subject, mediaType, serviceCenter, contentType, messageID, subscriptionID, addressesStr, sender sql.NullString var body, subject, mediaType, serviceCenter, contentType, messageID, subscriptionID, addressesStr, sender sql.NullString
var readInt, threadID, protocol, status, subID, readReport, readStatus, messageSize, messageTypeField, simSlot sql.NullInt64 var readInt, threadID, protocol, status, subID, readReport, readStatus, messageSize, messageTypeField, simSlot sql.NullInt64
var mediaData []byte
// Call fields // Call fields
var duration, presentation sql.NullInt64 var duration, presentation sql.NullInt64
err := rows.Scan(&recordType, &dateUnix, &address, &contactName, err := rows.Scan(&recordType, &dateUnix, &address, &contactName,
&id, &body, &itemType, &readInt, &threadID, &subject, &id, &body, &itemType, &readInt, &threadID, &subject,
&mediaType, &mediaData, &mediaType,
&protocol, &status, &serviceCenter, &protocol, &status, &serviceCenter,
&subID, &contentType, &readReport, &subID, &contentType, &readReport,
&readStatus, &messageID, &messageSize, &readStatus, &messageID, &messageSize,
@@ -788,7 +789,6 @@ func GetActivityByAddress(userDB *sql.DB, address string, startDate, endDate *ti
ThreadID: int(threadID.Int64), ThreadID: int(threadID.Int64),
Subject: subject.String, Subject: subject.String,
MediaType: mediaType.String, MediaType: mediaType.String,
MediaData: mediaData,
Protocol: int(protocol.Int64), Protocol: int(protocol.Int64),
Status: int(status.Int64), Status: int(status.Int64),
ServiceCenter: serviceCenter.String, ServiceCenter: serviceCenter.String,
@@ -822,10 +822,6 @@ func GetActivityByAddress(userDB *sql.DB, address string, startDate, endDate *ti
slog.Debug("GetActivityByAddress: addresses from address field", "id", id.Int64, "count", len(msg.Addresses), "values", msg.Addresses) slog.Debug("GetActivityByAddress: addresses from address field", "id", id.Int64, "count", len(msg.Addresses), "values", msg.Addresses)
} }
// Don't load media data - it will be fetched on demand via /api/media
// Clear MediaData to save memory in response
msg.MediaData = nil
slog.Debug("GetActivityByAddress: Message", "id", msg.ID, "address", msg.Address, "type", msg.Type, "sender", msg.Sender, "addresses", msg.Addresses, "media_type", msg.MediaType, "body", truncateString(msg.Body, 50)) slog.Debug("GetActivityByAddress: Message", "id", msg.ID, "address", msg.Address, "type", msg.Type, "sender", msg.Sender, "addresses", msg.Addresses, "media_type", msg.MediaType, "body", truncateString(msg.Body, 50))
activity.Message = msg activity.Message = msg
@@ -852,6 +848,27 @@ func GetActivityByAddress(userDB *sql.DB, address string, startDate, endDate *ti
return activities, nil return activities, nil
} }
// CountActivityByAddress returns the total number of activity rows for a given address and date range
func CountActivityByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time) (int, error) {
query := `SELECT COUNT(*) FROM messages WHERE 1=1`
args := []interface{}{}
if address != "" {
query += " AND address = ?"
args = append(args, address)
}
if startDate != nil {
query += " AND date >= ?"
args = append(args, startDate.Unix())
}
if endDate != nil {
query += " AND date <= ?"
args = append(args, endDate.Unix())
}
var count int
err := userDB.QueryRow(query, args...).Scan(&count)
return count, err
}
// GetMediaByAddress fetches only media items (images/videos) for a specific address // GetMediaByAddress fetches only media items (images/videos) for a specific address
func GetMediaByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time) ([]Message, error) { func GetMediaByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time) ([]Message, error) {
query := ` query := `
+27 -16
View File
@@ -176,8 +176,26 @@ func HandleMessages(c echo.Context) error {
// If type is "conversation", return combined messages and calls // If type is "conversation", return combined messages and calls
if convType == "conversation" { if convType == "conversation" {
// Parse limit and offset parameters // Get user ID from context to fetch settings
limit := 100000 // Default to 100k (effectively unlimited for most users) userID, ok := c.Get("user_id").(string)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "User not authenticated",
})
}
// Fetch user settings
settings, err := GetUserSettings(userID)
if err != nil {
slog.Error("Error getting user settings", "error", err)
settings = GetDefaultSettings()
}
// Use user's configured limit as default, allow query param override
limit := settings.Conversations.MessageLimit
if limit <= 0 {
limit = 100000
}
offset := 0 offset := 0
if limitStr := c.QueryParam("limit"); limitStr != "" { if limitStr := c.QueryParam("limit"); limitStr != "" {
@@ -192,20 +210,10 @@ func HandleMessages(c echo.Context) error {
} }
} }
// Get user ID from context to fetch settings total, err := CountActivityByAddress(userDB, address, startDate, endDate)
userID, ok := c.Get("user_id").(string)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "User not authenticated",
})
}
// Fetch user settings to check if calls should be shown
settings, err := GetUserSettings(userID)
if err != nil { if err != nil {
slog.Error("Error getting user settings", "error", err) slog.Error("Error counting activity", "error", err)
// If we can't get settings, default to showing calls total = 0
settings = GetDefaultSettings()
} }
activities, err := GetActivityByAddress(userDB, address, startDate, endDate, limit, offset) activities, err := GetActivityByAddress(userDB, address, startDate, endDate, limit, offset)
@@ -227,7 +235,10 @@ func HandleMessages(c echo.Context) error {
activities = filteredActivities activities = filteredActivities
} }
return c.JSON(http.StatusOK, activities) return c.JSON(http.StatusOK, map[string]interface{}{
"items": activities,
"total": total,
})
} }
messages, err := GetMessages(userDB, address, startDate, endDate) messages, err := GetMessages(userDB, address, startDate, endDate)
+4 -2
View File
@@ -16,14 +16,16 @@ type Settings struct {
// ConversationSettings contains settings for the conversation view // ConversationSettings contains settings for the conversation view
type ConversationSettings struct { type ConversationSettings struct {
ShowCalls bool `json:"show_calls"` ShowCalls bool `json:"show_calls"`
MessageLimit int `json:"message_limit"`
} }
// GetDefaultSettings returns the default settings // GetDefaultSettings returns the default settings
func GetDefaultSettings() Settings { func GetDefaultSettings() Settings {
return Settings{ return Settings{
Conversations: ConversationSettings{ Conversations: ConversationSettings{
ShowCalls: true, ShowCalls: true,
MessageLimit: 100000,
}, },
} }
} }
+1
View File
@@ -73,6 +73,7 @@ func main() {
// Middleware // Middleware
e.Use(middleware.Logger()) e.Use(middleware.Logger())
e.Use(middleware.Recover()) e.Use(middleware.Recover())
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{Level: 5}))
// Use custom CORS middleware that properly handles credentials // Use custom CORS middleware that properly handles credentials
e.Use(internal.CustomCORSMiddleware()) e.Use(internal.CustomCORSMiddleware())