Compare commits
10
Commits
275cbbfaf8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aac960298 | ||
|
|
40bd028efe | ||
|
|
47626f0c7c | ||
|
|
3fef1925d6 | ||
|
|
250b9030ea | ||
|
|
32d6110733 | ||
|
|
f839996050 | ||
|
|
e9d695d7c1 | ||
|
|
4397232f06 | ||
|
|
f89b214e4d |
@@ -16,32 +16,19 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
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
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -61,25 +48,110 @@ jobs:
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
# For tags, strip the 'v' prefix
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
else
|
||||
# For non-tags, use git commit hash with dirty flag
|
||||
VERSION=$(git describe --always --dirty)
|
||||
fi
|
||||
echo "version=git-${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Building with version: git-${VERSION}"
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
build:
|
||||
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:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ needs.prepare.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
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
|
||||
|
||||
@@ -49,6 +49,7 @@ services:
|
||||
- **Summary view** - View analytics about your messages
|
||||
- **vCard preview** - Preview the contents of contact cards (vCards)
|
||||
- **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
|
||||
|
||||
@@ -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?
|
||||
|
||||
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?
|
||||
|
||||
|
||||
+10
-16
@@ -5,30 +5,24 @@ set -e
|
||||
PUID="${PUID:-1000}"
|
||||
PGID="${PGID:-1000}"
|
||||
|
||||
# Create group if it doesn't exist
|
||||
if ! getent group sbv >/dev/null 2>&1; then
|
||||
# Use existing group if the GID is already taken, otherwise create one
|
||||
if ! getent group "${PGID}" >/dev/null 2>&1; then
|
||||
addgroup -g "${PGID}" sbv
|
||||
fi
|
||||
SBV_GROUP="$(getent group "${PGID}" | cut -d: -f1)"
|
||||
|
||||
# Create user if it doesn't exist
|
||||
if ! getent passwd sbv >/dev/null 2>&1; then
|
||||
adduser -D -u "${PUID}" -G sbv 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
|
||||
# Use existing user if the UID is already taken, otherwise create one
|
||||
if ! getent passwd "${PUID}" >/dev/null 2>&1; then
|
||||
adduser -D -u "${PUID}" -G "${SBV_GROUP}" sbv
|
||||
fi
|
||||
SBV_USER="$(getent passwd "${PUID}" | cut -d: -f1)"
|
||||
|
||||
# Ensure data directory exists and has correct permissions
|
||||
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
|
||||
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
|
||||
exec su-exec sbv "$@"
|
||||
exec su-exec "${SBV_USER}" "$@"
|
||||
|
||||
@@ -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;
|
||||
|
||||
+17
-12
@@ -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'
|
||||
@@ -34,7 +35,8 @@ function App() {
|
||||
const [version, setVersion] = useState('...')
|
||||
const [settings, setSettings] = useState({
|
||||
conversations: {
|
||||
show_calls: true
|
||||
show_calls: true,
|
||||
message_limit: 100000
|
||||
}
|
||||
})
|
||||
|
||||
@@ -89,7 +91,8 @@ function App() {
|
||||
// Use default settings if fetch fails
|
||||
setSettings({
|
||||
conversations: {
|
||||
show_calls: true
|
||||
show_calls: true,
|
||||
message_limit: 100000
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -188,7 +191,7 @@ function App() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="vh-100 d-flex flex-column bg-light">
|
||||
<div className="vh-100 d-flex flex-column bg-body-tertiary">
|
||||
{/* Header */}
|
||||
<header className="bg-primary bg-gradient text-white py-1 px-2 shadow" style={{zIndex: 1030}}>
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
@@ -199,6 +202,7 @@ function App() {
|
||||
<h1 className="h5 mb-0 fw-bold">SMS Backup Viewer</h1>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="btn btn-light btn-sm shadow-sm d-flex align-items-center gap-1"
|
||||
@@ -254,7 +258,7 @@ function App() {
|
||||
</header>
|
||||
|
||||
{/* View Switcher */}
|
||||
<div className="bg-white border-bottom shadow-sm">
|
||||
<div className="bg-body-tertiary border-bottom shadow-sm">
|
||||
<div className="container-fluid">
|
||||
<ul className="nav nav-tabs border-0">
|
||||
<li className="nav-item">
|
||||
@@ -317,7 +321,7 @@ function App() {
|
||||
</div>
|
||||
|
||||
{/* Date Filter */}
|
||||
<div className="date-filter-container bg-white border-bottom shadow-sm" style={{zIndex: 1025, position: 'relative'}}>
|
||||
<div className="date-filter-container bg-body-tertiary border-bottom shadow-sm" style={{zIndex: 1025, position: 'relative'}}>
|
||||
<DateFilter
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
@@ -334,9 +338,9 @@ function App() {
|
||||
<>
|
||||
{/* Conversation List */}
|
||||
<div
|
||||
className={`conversation-sidebar bg-white rounded-3 shadow overflow-hidden border ${showSidebar ? 'show' : ''}`}
|
||||
className={`conversation-sidebar bg-body-tertiary rounded-3 shadow overflow-hidden border ${showSidebar ? 'show' : ''}`}
|
||||
>
|
||||
<div className="bg-light border-bottom p-1">
|
||||
<div className="bg-body-tertiary border-bottom p-1">
|
||||
<h2 className="h6 mb-1 d-flex align-items-center gap-1 px-1">
|
||||
<svg style={{width: '1rem', height: '1rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
@@ -367,17 +371,18 @@ function App() {
|
||||
</div>
|
||||
|
||||
{/* Message Thread */}
|
||||
<div className="flex-fill bg-white rounded-3 shadow overflow-hidden border message-thread-container" style={{minWidth: 0}}>
|
||||
<div className="flex-fill bg-body-secondary rounded-3 shadow overflow-hidden border message-thread-container" style={{minWidth: 0}}>
|
||||
<MessageThread
|
||||
conversation={selectedConversation}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
messageLimit={settings.conversations.message_limit}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : activeView === 'search' ? (
|
||||
/* Search View */
|
||||
<div className="flex-fill bg-white rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||
<div className="flex-fill bg-body-secondary rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||
<Search
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
@@ -393,7 +398,7 @@ function App() {
|
||||
</div>
|
||||
) : activeView === 'calls' ? (
|
||||
/* Calls View */
|
||||
<div className="flex-fill bg-white rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||
<div className="flex-fill bg-body-secondary rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||
<Calls
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
@@ -401,7 +406,7 @@ function App() {
|
||||
</div>
|
||||
) : activeView === 'summary' ? (
|
||||
/* Summary View */
|
||||
<div className="flex-fill bg-white rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||
<div className="flex-fill bg-body-secondary rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||
<Summary
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
@@ -409,7 +414,7 @@ function App() {
|
||||
</div>
|
||||
) : (
|
||||
/* Activity View */
|
||||
<div className="flex-fill bg-white rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||
<div className="flex-fill bg-body-secondary rounded-3 shadow overflow-hidden border" style={{minWidth: 0}}>
|
||||
<Activity
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
|
||||
@@ -266,7 +266,7 @@ function Activity({ startDate, endDate }) {
|
||||
|
||||
return (
|
||||
<div className="h-100 d-flex flex-column">
|
||||
<div className="bg-light border-bottom p-3">
|
||||
<div className="bg-body-tertiary border-bottom p-3">
|
||||
<h2 className="h5 mb-0 d-flex align-items-center gap-2">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
|
||||
@@ -200,7 +200,7 @@ function Calls({ startDate, endDate }) {
|
||||
|
||||
return (
|
||||
<div className="h-100 d-flex flex-column">
|
||||
<div className="bg-light border-bottom p-3">
|
||||
<div className="bg-body-tertiary border-bottom p-3">
|
||||
<h2 className="h5 mb-0 d-flex align-items-center gap-2">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
|
||||
@@ -133,7 +133,7 @@ function ConversationList({ conversations, selectedConversation, onSelectConvers
|
||||
style={{cursor: 'pointer'}}
|
||||
>
|
||||
<div className="d-flex align-items-start gap-2">
|
||||
<div className="flex-shrink-0 mt-1 p-2 rounded-circle bg-white shadow-sm">
|
||||
<div className="flex-shrink-0 mt-1 p-2 rounded-circle bg-body-tertiary shadow-sm">
|
||||
{getConversationIcon(conv.type)}
|
||||
</div>
|
||||
<div className="flex-fill min-w-0" style={{overflow: 'hidden'}}>
|
||||
|
||||
@@ -8,7 +8,7 @@ function DateFilter({ startDate, endDate, minDate, maxDate, onStartDateChange, o
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-2 py-1 bg-light">
|
||||
<div className="px-2 py-1 bg-body-tertiary">
|
||||
<div className="d-flex align-items-center gap-1 gap-sm-2 small">
|
||||
<svg style={{width: '1rem', height: '1rem'}} className="text-primary flex-shrink-0 d-none d-sm-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
|
||||
@@ -184,7 +184,7 @@ function LazyMedia({ messageId, mediaType, className, alt = "MMS attachment" })
|
||||
{/* Placeholder shown before loading or while loading */}
|
||||
{!src && !vcfData && !error && (
|
||||
<div
|
||||
className="bg-light rounded d-flex align-items-center justify-content-center position-relative overflow-hidden"
|
||||
className="bg-body-tertiary rounded d-flex align-items-center justify-content-center position-relative overflow-hidden"
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: isVideo ? '16/9' : isAudio ? 'auto' : '3/4', // Common phone camera ratio
|
||||
@@ -307,7 +307,7 @@ function LazyMedia({ messageId, mediaType, className, alt = "MMS attachment" })
|
||||
<VCardPreview vcfText={vcfData} messageId={messageId} />
|
||||
)}
|
||||
{!isImage && !isVideo && !isAudio && !isVCard && (
|
||||
<div className="small p-2 rounded bg-light d-flex align-items-center gap-1">
|
||||
<div className="small p-2 rounded bg-body-tertiary d-flex align-items-center gap-1">
|
||||
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
|
||||
function Login() {
|
||||
const [isLogin, setIsLogin] = useState(true)
|
||||
@@ -64,7 +65,10 @@ function Login() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-vh-100 d-flex align-items-center justify-content-center bg-light">
|
||||
<div className="min-vh-100 d-flex align-items-center justify-content-center bg-body-tertiary position-relative">
|
||||
<div className="position-absolute top-0 end-0 p-3" style={{ zIndex: 10 }}>
|
||||
<ThemeToggle variant="surface" />
|
||||
</div>
|
||||
<div className="container">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6 col-lg-4">
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Dark mode: bring the placeholder tile in line with Bootstrap's body-tertiary value. */
|
||||
html[data-bs-theme="dark"] .media-grid-item {
|
||||
background-color: #2c3034;
|
||||
}
|
||||
|
||||
.media-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
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'
|
||||
@@ -7,24 +8,53 @@ import MediaGrid from './MediaGrid'
|
||||
|
||||
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 [items, setItems] = useState([])
|
||||
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 [isPreprintingMedia, setIsPreprintingMedia] = useState(false)
|
||||
const [showMediaOnly, setShowMediaOnly] = useState(false)
|
||||
const messageRefs = useRef({})
|
||||
const printTriggeredRef = useRef(false)
|
||||
const scrollContainerRef = useRef(null)
|
||||
const suppressAutoScrollRef = useRef(false)
|
||||
const scrollToItemIdRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (conversation) {
|
||||
setOffset(0)
|
||||
setTailOffset(0)
|
||||
setTotalCount(0)
|
||||
setItems([])
|
||||
fetchItems()
|
||||
setShowMediaOnly(false) // Reset to message view when conversation changes
|
||||
setShowMediaOnly(false)
|
||||
} else {
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -104,6 +134,11 @@ function MessageThread({ conversation, startDate, endDate }) {
|
||||
|
||||
// Automatically scroll to the last message when opening a conversation
|
||||
useEffect(() => {
|
||||
if (suppressAutoScrollRef.current) {
|
||||
suppressAutoScrollRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (items.length > 0) {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const messageId = params.get('messageId')
|
||||
@@ -229,19 +264,119 @@ function MessageThread({ conversation, startDate, endDate }) {
|
||||
const fetchItems = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const limit = messageLimit || 100000
|
||||
const params = {
|
||||
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 (endDate) params.end = endDate.toISOString()
|
||||
|
||||
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) {
|
||||
console.error('Error fetching items:', error)
|
||||
console.error('Error fetching older items:', error)
|
||||
} 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) {
|
||||
return (
|
||||
<div className="d-flex align-items-center justify-content-center h-100 text-muted">
|
||||
<div className="d-flex align-items-center justify-content-center h-100 text-body-secondary">
|
||||
<div className="text-center">
|
||||
<svg style={{width: '5rem', height: '5rem'}} className="mx-auto mb-3 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<p className="h5 text-dark">Select a conversation</p>
|
||||
<p className="h5 text-body-emphasis mb-2">Select a conversation</p>
|
||||
<p className="small mt-2">Choose a conversation from the list to view messages</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -426,7 +561,7 @@ function MessageThread({ conversation, startDate, endDate }) {
|
||||
)}
|
||||
|
||||
{/* Thread Header */}
|
||||
<div className="bg-light border-bottom p-2 p-md-4 shadow-sm">
|
||||
<div className="bg-body-tertiary border-bottom p-2 p-md-4 shadow-sm">
|
||||
<div className="d-flex align-items-center gap-2 gap-md-3">
|
||||
<div className="p-2 p-md-3 rounded-circle bg-primary bg-gradient shadow">
|
||||
{isCallLog ? (
|
||||
@@ -463,7 +598,14 @@ function MessageThread({ conversation, startDate, endDate }) {
|
||||
})()}
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -493,7 +635,7 @@ function MessageThread({ conversation, startDate, endDate }) {
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
// Media Grid View
|
||||
<MediaGrid conversation={conversation} startDate={startDate} endDate={endDate} />
|
||||
@@ -541,8 +683,26 @@ function MessageThread({ conversation, startDate, endDate }) {
|
||||
) : (
|
||||
// Unified Message and Call View
|
||||
<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) => {
|
||||
// Check if this is an ActivityItem (has type field) or a direct Message
|
||||
const isActivityItem = item.type === 'message' || item.type === 'call'
|
||||
const isCall = isActivityItem && item.type === 'call'
|
||||
const message = isActivityItem ? item.message : item
|
||||
@@ -553,7 +713,7 @@ function MessageThread({ conversation, startDate, endDate }) {
|
||||
const typeInfo = getCallTypeInfo(call.type)
|
||||
return (
|
||||
<div key={`call-${call.id}`} className="d-flex justify-content-center my-1">
|
||||
<div className="badge bg-light text-dark border px-3 py-2 d-flex align-items-center gap-2" style={{fontSize: '0.75rem'}}>
|
||||
<div className="badge bg-body-secondary text-body-emphasis border px-3 py-2 d-flex align-items-center gap-2" style={{fontSize: '0.75rem'}}>
|
||||
<span className={typeInfo.color} style={{fontSize: '1rem'}}>{typeInfo.icon}</span>
|
||||
<span className={`fw-semibold ${typeInfo.color}`}>{typeInfo.label} call</span>
|
||||
<span className="text-muted">·</span>
|
||||
@@ -593,7 +753,9 @@ function MessageThread({ conversation, startDate, endDate }) {
|
||||
className={`card shadow-sm ${
|
||||
isSent
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-white'
|
||||
: isDark
|
||||
? 'bg-body-tertiary text-body'
|
||||
: 'bg-white'
|
||||
} ${
|
||||
isHighlighted
|
||||
? 'border-warning border-3'
|
||||
@@ -635,6 +797,25 @@ function MessageThread({ conversation, startDate, endDate }) {
|
||||
</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>
|
||||
|
||||
@@ -77,7 +77,7 @@ function Search({ searchQuery, setSearchQuery, results, setResults, loading, set
|
||||
return (
|
||||
<div className="h-100 d-flex flex-column">
|
||||
{/* Header */}
|
||||
<div className="bg-light border-bottom p-3">
|
||||
<div className="bg-body-tertiary border-bottom p-3">
|
||||
<h2 className="h5 mb-3 d-flex align-items-center gap-2">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
|
||||
@@ -3,10 +3,20 @@ import axios from 'axios'
|
||||
|
||||
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 }) {
|
||||
const [settings, setSettings] = useState({
|
||||
conversations: {
|
||||
show_calls: true
|
||||
show_calls: true,
|
||||
message_limit: 100000
|
||||
}
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -38,7 +48,6 @@ function SettingsModal({ show, onClose, onSettingsUpdated }) {
|
||||
setError('')
|
||||
await axios.put(`${API_BASE}/settings`, settings)
|
||||
|
||||
// Notify parent component that settings were updated
|
||||
if (onSettingsUpdated) {
|
||||
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
|
||||
|
||||
return (
|
||||
@@ -89,7 +108,7 @@ function SettingsModal({ show, onClose, onSettingsUpdated }) {
|
||||
|
||||
<h6 className="mb-3">Conversations</h6>
|
||||
|
||||
<div className="form-check form-switch">
|
||||
<div className="form-check form-switch mb-3">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
@@ -105,6 +124,24 @@ function SettingsModal({ show, onClose, onSettingsUpdated }) {
|
||||
When enabled, phone calls will appear in the conversation list alongside messages.
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Summary charts — dark-mode surface + readable axis/tooltip text.
|
||||
*
|
||||
* Selectors are intentionally scoped to the Summary page only, using the
|
||||
* existing `.card` + `.border-*` stat cards and the plain `.card` chart
|
||||
* cards that are already in the DOM from Summary.jsx. No JSX changes are
|
||||
* required; this file is imported only by Summary.jsx.
|
||||
*/
|
||||
|
||||
/* Stats cards / chart cards: re-colorize the card body in dark mode. */
|
||||
html[data-bs-theme="dark"] .summary-view .card {
|
||||
background-color: var(--bs-body-tertiary);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .summary-view .card-body,
|
||||
html[data-bs-theme="dark"] .summary-view .border-primary .card-body,
|
||||
html[data-bs-theme="dark"] .summary-view .border-success .card-body,
|
||||
html[data-bs-theme="dark"] .summary-view .border-info .card-body,
|
||||
html[data-bs-theme="dark"] .summary-view .border-warning .card-body {
|
||||
background-color: var(--bs-body-secondary);
|
||||
}
|
||||
|
||||
/* Recharts SVG canvas */
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-wrapper {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
/* Axis lines and ticks */
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-cartesian-axis-line,
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-polar-axis-angle-axis line,
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-polar-axis-radial-axis line {
|
||||
stroke: var(--bs-border-color);
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-cartesian-axis-tick-value,
|
||||
html[data-bs-theme="dark"]
|
||||
.summary-view
|
||||
.recharts-polar-axis-angle-axis-tick-value,
|
||||
html[data-bs-theme="dark"]
|
||||
.summary-view
|
||||
.recharts-polar-axis-radial-axis-tick-value {
|
||||
fill: var(--bs-body-color);
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
html[data-bs-theme="dark"]
|
||||
.summary-view
|
||||
.recharts-cartesian-grid-horizontal
|
||||
line,
|
||||
html[data-bs-theme="dark"]
|
||||
.summary-view
|
||||
.recharts-cartesian-grid-vertical
|
||||
line {
|
||||
stroke: var(--bs-border-color);
|
||||
}
|
||||
|
||||
/* Tooltip popover */
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-tooltip-wrapper {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-default-tooltip {
|
||||
background-color: var(--bs-body-tertiary) !important;
|
||||
border-color: var(--bs-border-color) !important;
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
|
||||
/* Legend */
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-legend-item-text {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
/* Label text */
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-pie-label-text,
|
||||
html[data-bs-theme="dark"] .summary-view .recharts-bar-label-text {
|
||||
fill: var(--bs-body-color);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
PieChart, Pie, Cell, LineChart, Line, Legend
|
||||
} from 'recharts'
|
||||
import './Summary.css'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8085/api'
|
||||
|
||||
@@ -114,8 +115,8 @@ function Summary({ startDate, endDate }) {
|
||||
const truncate = (str, max) => str.length > max ? str.slice(0, max) + '…' : str
|
||||
|
||||
return (
|
||||
<div className="h-100 d-flex flex-column">
|
||||
<div className="bg-light border-bottom p-3">
|
||||
<div className="h-100 d-flex flex-column summary-view">
|
||||
<div className="bg-body-tertiary border-bottom p-3">
|
||||
<h2 className="h5 mb-0 d-flex align-items-center gap-2">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
|
||||
const THEME_LABELS = {
|
||||
light: 'Light mode',
|
||||
dark: 'Dark mode',
|
||||
system: 'System theme',
|
||||
}
|
||||
|
||||
const NEXT_THEME_LABELS = {
|
||||
light: 'dark mode',
|
||||
dark: 'system theme',
|
||||
system: 'light mode',
|
||||
}
|
||||
|
||||
function ThemeToggle({ variant = 'header' }) {
|
||||
const { theme, resolvedTheme, toggle } = useTheme()
|
||||
const variantClass =
|
||||
variant === 'surface' ? 'theme-toggle-btn-surface' : 'theme-toggle-btn'
|
||||
|
||||
const title = `${THEME_LABELS[theme]}${theme === 'system' ? ` (${resolvedTheme})` : ''}. Switch to ${NEXT_THEME_LABELS[theme]}.`
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className={`btn btn-sm ${variantClass} p-1 d-inline-flex align-items-center justify-content-center`}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
|
||||
</svg>
|
||||
) : theme === 'light' ? (
|
||||
<svg
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeToggle
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
|
||||
const STORAGE_KEY = 'sbv-theme'
|
||||
const THEME_ORDER = ['light', 'dark', 'system']
|
||||
|
||||
const ThemeContext = createContext(null)
|
||||
|
||||
function getSystemTheme() {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
}
|
||||
return 'light'
|
||||
}
|
||||
|
||||
function resolveTheme(preference) {
|
||||
if (preference === 'system') {
|
||||
return getSystemTheme()
|
||||
}
|
||||
return preference
|
||||
}
|
||||
|
||||
function readStoredPreference() {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved === 'dark' || saved === 'light' || saved === 'system') {
|
||||
return saved
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable (e.g. private mode on some browsers)
|
||||
}
|
||||
return 'system'
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setTheme] = useState(readStoredPreference)
|
||||
const [resolvedTheme, setResolvedTheme] = useState(() =>
|
||||
resolveTheme(readStoredPreference()),
|
||||
)
|
||||
|
||||
const applyResolvedTheme = useCallback((resolved) => {
|
||||
setResolvedTheme(resolved)
|
||||
document.documentElement.setAttribute('data-bs-theme', resolved)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
applyResolvedTheme(resolveTheme(theme))
|
||||
}, [theme, applyResolvedTheme])
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== 'system' || !window.matchMedia) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const onChange = () => applyResolvedTheme(getSystemTheme())
|
||||
|
||||
mediaQuery.addEventListener('change', onChange)
|
||||
return () => mediaQuery.removeEventListener('change', onChange)
|
||||
}, [theme, applyResolvedTheme])
|
||||
|
||||
const toggle = () => {
|
||||
setTheme((current) => {
|
||||
const index = THEME_ORDER.indexOf(current)
|
||||
const next = THEME_ORDER[(index + 1) % THEME_ORDER.length]
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolvedTheme, toggle }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,8 +1,121 @@
|
||||
/* Theme toggle in primary header: high contrast on blue gradient */
|
||||
.theme-toggle-btn {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.22);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 0.375rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover,
|
||||
.theme-toggle-btn:focus-visible {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.35);
|
||||
border-color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
/* Theme toggle on body/card surfaces (e.g. login page) */
|
||||
.theme-toggle-btn-surface {
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.375rem;
|
||||
line-height: 1;
|
||||
box-shadow: var(--bs-box-shadow-sm);
|
||||
}
|
||||
|
||||
.theme-toggle-btn-surface:hover,
|
||||
.theme-toggle-btn-surface:focus-visible {
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-secondary-bg);
|
||||
border-color: var(--bs-border-color-translucent, var(--bs-border-color));
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
|
||||
/* Dark mode: ensure body inherits Bootstrap's dark custom properties. */
|
||||
html[data-bs-theme="dark"] body {
|
||||
background-color: #212529;
|
||||
color: #dee2e6;
|
||||
}
|
||||
|
||||
/* Dark mode: legacy utilities that force light-theme colors */
|
||||
html[data-bs-theme="dark"] .text-dark {
|
||||
color: var(--bs-body-emphasis-color) !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .bg-light.text-dark,
|
||||
html[data-bs-theme="dark"] .badge.bg-light {
|
||||
background-color: var(--bs-secondary-bg) !important;
|
||||
color: var(--bs-body-color) !important;
|
||||
border-color: var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
/* Dark mode: slightly brighter secondary copy on tertiary surfaces */
|
||||
html[data-bs-theme="dark"] .text-muted,
|
||||
html[data-bs-theme="dark"] .text-body-secondary {
|
||||
color: #adb5bd !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .text-body-emphasis {
|
||||
color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
/* Dark mode: react-datepicker popup and inputs */
|
||||
html[data-bs-theme="dark"] .react-datepicker {
|
||||
background-color: var(--bs-body-bg);
|
||||
border-color: var(--bs-border-color);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .react-datepicker__header {
|
||||
background-color: var(--bs-secondary-bg);
|
||||
border-bottom-color: var(--bs-border-color);
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .react-datepicker__current-month,
|
||||
html[data-bs-theme="dark"] .react-datepicker__day-name,
|
||||
html[data-bs-theme="dark"] .react-datepicker__day,
|
||||
html[data-bs-theme="dark"] .react-datepicker__time-name {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .react-datepicker__day:hover,
|
||||
html[data-bs-theme="dark"] .react-datepicker__day--keyboard-selected {
|
||||
background-color: var(--bs-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .react-datepicker__day--outside-month {
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .react-datepicker__navigation-icon::before {
|
||||
border-color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
/* Dark mode: subdued scrollbar for WebKit/Blink */
|
||||
html[data-bs-theme="dark"] ::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] ::-webkit-scrollbar-track {
|
||||
background: #1a1d20;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] ::-webkit-scrollbar-thumb {
|
||||
background: #495057;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] ::-webkit-scrollbar-thumb:hover {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
+18
-15
@@ -5,6 +5,7 @@ import axios from 'axios'
|
||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||
import './index.css'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import App from './App.jsx'
|
||||
import Login from './components/Login.jsx'
|
||||
import PrintView from './components/PrintView.jsx'
|
||||
@@ -16,21 +17,23 @@ axios.defaults.withCredentials = true
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/conversation/:address/print" element={
|
||||
<ProtectedRoute>
|
||||
<PrintView />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/*" element={
|
||||
<ProtectedRoute>
|
||||
<App />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/conversation/:address/print" element={
|
||||
<ProtectedRoute>
|
||||
<PrintView />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/*" element={
|
||||
<ProtectedRoute>
|
||||
<App />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
+26
-9
@@ -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_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_address_date ON messages(address, date);
|
||||
|
||||
-- Create unique constraints for idempotent imports
|
||||
-- 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_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_address_date ON messages(address, date);
|
||||
|
||||
-- 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));
|
||||
@@ -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) {
|
||||
var activities []ActivityItem
|
||||
|
||||
// Query from unified table
|
||||
// Query from unified table — media_data is intentionally excluded; fetched on-demand via /api/media
|
||||
query := `
|
||||
SELECT record_type, date, address, COALESCE(contact_name, '') as contact_name,
|
||||
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(sub_id, 0), COALESCE(content_type, ''), COALESCE(read_report, 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
|
||||
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 mediaData []byte
|
||||
|
||||
// Call fields
|
||||
var duration, presentation sql.NullInt64
|
||||
|
||||
err := rows.Scan(&recordType, &dateUnix, &address, &contactName,
|
||||
&id, &body, &itemType, &readInt, &threadID, &subject,
|
||||
&mediaType, &mediaData,
|
||||
&mediaType,
|
||||
&protocol, &status, &serviceCenter,
|
||||
&subID, &contentType, &readReport,
|
||||
&readStatus, &messageID, &messageSize,
|
||||
@@ -788,7 +789,6 @@ func GetActivityByAddress(userDB *sql.DB, address string, startDate, endDate *ti
|
||||
ThreadID: int(threadID.Int64),
|
||||
Subject: subject.String,
|
||||
MediaType: mediaType.String,
|
||||
MediaData: mediaData,
|
||||
Protocol: int(protocol.Int64),
|
||||
Status: int(status.Int64),
|
||||
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)
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
activity.Message = msg
|
||||
@@ -852,6 +848,27 @@ func GetActivityByAddress(userDB *sql.DB, address string, startDate, endDate *ti
|
||||
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
|
||||
func GetMediaByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time) ([]Message, error) {
|
||||
query := `
|
||||
|
||||
+27
-16
@@ -176,8 +176,26 @@ func HandleMessages(c echo.Context) error {
|
||||
|
||||
// If type is "conversation", return combined messages and calls
|
||||
if convType == "conversation" {
|
||||
// Parse limit and offset parameters
|
||||
limit := 100000 // Default to 100k (effectively unlimited for most users)
|
||||
// Get user ID from context to fetch settings
|
||||
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
|
||||
|
||||
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
||||
@@ -192,20 +210,10 @@ func HandleMessages(c echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Get user ID from context to fetch settings
|
||||
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)
|
||||
total, err := CountActivityByAddress(userDB, address, startDate, endDate)
|
||||
if err != nil {
|
||||
slog.Error("Error getting user settings", "error", err)
|
||||
// If we can't get settings, default to showing calls
|
||||
settings = GetDefaultSettings()
|
||||
slog.Error("Error counting activity", "error", err)
|
||||
total = 0
|
||||
}
|
||||
|
||||
activities, err := GetActivityByAddress(userDB, address, startDate, endDate, limit, offset)
|
||||
@@ -227,7 +235,10 @@ func HandleMessages(c echo.Context) error {
|
||||
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)
|
||||
|
||||
@@ -16,14 +16,16 @@ type Settings struct {
|
||||
|
||||
// ConversationSettings contains settings for the conversation view
|
||||
type ConversationSettings struct {
|
||||
ShowCalls bool `json:"show_calls"`
|
||||
ShowCalls bool `json:"show_calls"`
|
||||
MessageLimit int `json:"message_limit"`
|
||||
}
|
||||
|
||||
// GetDefaultSettings returns the default settings
|
||||
func GetDefaultSettings() Settings {
|
||||
return Settings{
|
||||
Conversations: ConversationSettings{
|
||||
ShowCalls: true,
|
||||
ShowCalls: true,
|
||||
MessageLimit: 100000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user