Initial Upload
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
40
.dockerignore
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Dependencies - will be installed fresh in container
|
||||||
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
|
# Turbo cache - not needed in Docker build
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Testing artifacts
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Environment files - should be provided at runtime
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Docker files (avoid recursion)
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Documentation (not needed in image)
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# CI/CD
|
||||||
|
.github/
|
||||||
15
.editorconfig
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
21
.env.example
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL=postgres://tracearr:tracearr@localhost:5432/tracearr
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=3000
|
||||||
|
HOST=0.0.0.0
|
||||||
|
NODE_ENV=development
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# Auth - CHANGE THESE IN PRODUCTION!
|
||||||
|
JWT_SECRET=your-secret-key-change-in-production
|
||||||
|
COOKIE_SECRET=your-cookie-secret-change-in-production
|
||||||
|
|
||||||
|
# CORS (frontend URL)
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
|
# Mobile beta testing mode - enables reusable tokens, no expiry, unlimited devices
|
||||||
|
# MOBILE_BETA_MODE=true
|
||||||
5
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: connorgallopo
|
||||||
|
patreon: Gallapagos
|
||||||
|
thanks_dev: connorgallopo
|
||||||
266
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-typecheck:
|
||||||
|
name: Lint & Typecheck
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
env:
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Cache Turborepo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .turbo
|
||||||
|
key: ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||||
|
${{ runner.os }}-turbo-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Test (${{ matrix.group }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
group: [unit, services, routes, security]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Cache Turborepo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .turbo
|
||||||
|
key: ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||||
|
${{ runner.os }}-turbo-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build dependencies (shared, test-utils)
|
||||||
|
run: pnpm turbo run build --filter=@tracearr/shared --filter=@tracearr/test-utils
|
||||||
|
|
||||||
|
- name: Run ${{ matrix.group }} tests
|
||||||
|
run: pnpm --filter @tracearr/server test:${{ matrix.group }}
|
||||||
|
|
||||||
|
# Integration tests require real database - run separately from unit tests
|
||||||
|
test-integration:
|
||||||
|
name: Test (integration)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
TEST_DATABASE_URL: postgresql://test:test@localhost:5433/tracearr_test
|
||||||
|
TEST_REDIS_URL: redis://localhost:6380
|
||||||
|
|
||||||
|
services:
|
||||||
|
timescale:
|
||||||
|
image: timescale/timescaledb:latest-pg15
|
||||||
|
ports:
|
||||||
|
- 5433:5432
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: test
|
||||||
|
POSTGRES_PASSWORD: test
|
||||||
|
POSTGRES_DB: tracearr_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U test -d tracearr_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 10
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- 6380:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Cache Turborepo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .turbo
|
||||||
|
key: ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||||
|
${{ runner.os }}-turbo-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build dependencies (shared, test-utils)
|
||||||
|
run: pnpm turbo run build --filter=@tracearr/shared --filter=@tracearr/test-utils
|
||||||
|
|
||||||
|
- name: Run integration tests
|
||||||
|
run: pnpm --filter @tracearr/server test:integration
|
||||||
|
|
||||||
|
# Separate job for coverage reporting (runs all tests together for accurate coverage)
|
||||||
|
# Runs in parallel with test matrix - if tests pass there, coverage will too
|
||||||
|
test-coverage:
|
||||||
|
name: Test Coverage
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Cache Turborepo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .turbo
|
||||||
|
key: ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||||
|
${{ runner.os }}-turbo-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build dependencies (shared, test-utils)
|
||||||
|
run: pnpm turbo run build --filter=@tracearr/shared --filter=@tracearr/test-utils
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: pnpm test:coverage
|
||||||
|
|
||||||
|
- name: Upload coverage reports
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: coverage-report
|
||||||
|
path: apps/server/coverage/
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
- name: Report coverage to PR
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: davelosert/vitest-coverage-report-action@v2
|
||||||
|
with:
|
||||||
|
working-directory: apps/server
|
||||||
|
vite-config-path: vitest.config.ts
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
needs: [lint-and-typecheck, test, test-integration, test-coverage]
|
||||||
|
env:
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Cache Turborepo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .turbo
|
||||||
|
key: ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||||
|
${{ runner.os }}-turbo-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
454
.github/workflows/mobile-build.yml
vendored
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
name: Mobile Build (Local)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Git tag to build (e.g., v1.2.3-beta.1)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
platform:
|
||||||
|
description: 'Platform to build'
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- android
|
||||||
|
- ios
|
||||||
|
- both
|
||||||
|
default: 'both'
|
||||||
|
distribution:
|
||||||
|
description: 'Distribution method'
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- internal
|
||||||
|
- store
|
||||||
|
default: 'internal'
|
||||||
|
profile:
|
||||||
|
description: 'Build profile'
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- preview
|
||||||
|
- production
|
||||||
|
default: 'preview'
|
||||||
|
|
||||||
|
# Prevent concurrent builds that could cause version conflicts
|
||||||
|
concurrency:
|
||||||
|
group: mobile-build
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Atomically prepare and reserve version numbers before builds start
|
||||||
|
prepare-versions:
|
||||||
|
name: Prepare Versions
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: apps/mobile
|
||||||
|
outputs:
|
||||||
|
marketing-version: ${{ steps.versions.outputs.marketing-version }}
|
||||||
|
android-version-code: ${{ steps.versions.outputs.android-version-code }}
|
||||||
|
ios-build-number: ${{ steps.versions.outputs.ios-build-number }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: Setup Expo
|
||||||
|
uses: expo/expo-github-action@main
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Reserve version numbers
|
||||||
|
id: versions
|
||||||
|
env:
|
||||||
|
EAS_PROJECT_ID: "2e0b9595-ac62-493f-9a10-4f8758bb4b2d"
|
||||||
|
APP_IDENTIFIER: "com.tracearr.app"
|
||||||
|
run: |
|
||||||
|
# Extract marketing version from git tag
|
||||||
|
VERSION="${{ inputs.tag }}"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
VERSION="${VERSION%%-*}"
|
||||||
|
echo "Marketing version: $VERSION"
|
||||||
|
echo "marketing-version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Get current versions from EAS remote
|
||||||
|
echo "Getting current versions from EAS remote..."
|
||||||
|
REMOTE_VERSIONS=$(eas build:version:get --platform all --json --non-interactive 2>/dev/null || echo '{}')
|
||||||
|
echo "Remote versions: $REMOTE_VERSIONS"
|
||||||
|
|
||||||
|
# Parse current versions (default to 0 if not set)
|
||||||
|
CURRENT_ANDROID_VC=$(echo "$REMOTE_VERSIONS" | jq -r '.versionCode // "0"')
|
||||||
|
CURRENT_IOS_BN=$(echo "$REMOTE_VERSIONS" | jq -r '.buildNumber // "0"')
|
||||||
|
|
||||||
|
# Increment versions
|
||||||
|
NEXT_ANDROID_VC=$((CURRENT_ANDROID_VC + 1))
|
||||||
|
NEXT_IOS_BN=$((CURRENT_IOS_BN + 1))
|
||||||
|
|
||||||
|
echo "Android versionCode: $CURRENT_ANDROID_VC -> $NEXT_ANDROID_VC"
|
||||||
|
echo "iOS buildNumber: $CURRENT_IOS_BN -> $NEXT_IOS_BN"
|
||||||
|
|
||||||
|
# Reserve versions on EAS remote via GraphQL API
|
||||||
|
echo "Reserving Android version $NEXT_ANDROID_VC on EAS..."
|
||||||
|
ANDROID_RESULT=$(curl -sf -X POST https://api.expo.dev/graphql \
|
||||||
|
-H "Authorization: Bearer $EXPO_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"query\":\"mutation { appVersion { createAppVersion(appVersionInput: { appId: \\\"$EAS_PROJECT_ID\\\", platform: ANDROID, applicationIdentifier: \\\"$APP_IDENTIFIER\\\", storeVersion: \\\"$VERSION\\\", buildVersion: \\\"$NEXT_ANDROID_VC\\\" }) { id } } }\"}")
|
||||||
|
echo "Android result: $ANDROID_RESULT"
|
||||||
|
|
||||||
|
echo "Reserving iOS version $NEXT_IOS_BN on EAS..."
|
||||||
|
IOS_RESULT=$(curl -sf -X POST https://api.expo.dev/graphql \
|
||||||
|
-H "Authorization: Bearer $EXPO_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"query\":\"mutation { appVersion { createAppVersion(appVersionInput: { appId: \\\"$EAS_PROJECT_ID\\\", platform: IOS, applicationIdentifier: \\\"$APP_IDENTIFIER\\\", storeVersion: \\\"$VERSION\\\", buildVersion: \\\"$NEXT_IOS_BN\\\" }) { id } } }\"}")
|
||||||
|
echo "iOS result: $IOS_RESULT"
|
||||||
|
|
||||||
|
# Output for build jobs
|
||||||
|
echo "android-version-code=$NEXT_ANDROID_VC" >> $GITHUB_OUTPUT
|
||||||
|
echo "ios-build-number=$NEXT_IOS_BN" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
echo "Versions reserved successfully"
|
||||||
|
|
||||||
|
build-android:
|
||||||
|
name: Build Android
|
||||||
|
needs: [prepare-versions]
|
||||||
|
if: inputs.platform == 'android' || inputs.platform == 'both'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: apps/mobile
|
||||||
|
outputs:
|
||||||
|
artifact-path: ${{ steps.build.outputs.artifact-path }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: Free disk space
|
||||||
|
uses: jlumbroso/free-disk-space@main
|
||||||
|
with:
|
||||||
|
android: false
|
||||||
|
dotnet: true
|
||||||
|
haskell: true
|
||||||
|
large-packages: true
|
||||||
|
docker-images: true
|
||||||
|
swap-storage: true
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: Setup Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: Build shared package
|
||||||
|
run: pnpm --filter @tracearr/shared build
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: Setup Expo
|
||||||
|
uses: expo/expo-github-action@main
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set versions from prepare job
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.prepare-versions.outputs.marketing-version }}"
|
||||||
|
VERSION_CODE="${{ needs.prepare-versions.outputs.android-version-code }}"
|
||||||
|
|
||||||
|
echo "Setting version: $VERSION, versionCode: $VERSION_CODE"
|
||||||
|
|
||||||
|
jq --arg v "$VERSION" \
|
||||||
|
--argjson vc "$VERSION_CODE" \
|
||||||
|
'.expo.version = $v | .expo.android.versionCode = $vc' \
|
||||||
|
app.json > tmp.json && mv tmp.json app.json
|
||||||
|
|
||||||
|
- name: Build Android locally
|
||||||
|
id: build
|
||||||
|
env:
|
||||||
|
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
||||||
|
run: |
|
||||||
|
BUILD_TYPE="${{ inputs.distribution == 'internal' && 'apk' || 'app-bundle' }}"
|
||||||
|
OUTPUT_EXT="${{ inputs.distribution == 'internal' && 'apk' || 'aab' }}"
|
||||||
|
OUTPUT_FILE="tracearr-${{ inputs.tag }}-android.${OUTPUT_EXT}"
|
||||||
|
|
||||||
|
echo "Building $BUILD_TYPE for ${{ inputs.profile }} profile..."
|
||||||
|
|
||||||
|
eas build \
|
||||||
|
--platform android \
|
||||||
|
--profile ${{ inputs.profile }} \
|
||||||
|
--local \
|
||||||
|
--output "./${OUTPUT_FILE}" \
|
||||||
|
--non-interactive
|
||||||
|
|
||||||
|
echo "artifact-path=apps/mobile/${OUTPUT_FILE}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Build complete: ${OUTPUT_FILE}"
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: android-build
|
||||||
|
path: ${{ steps.build.outputs.artifact-path }}
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
build-ios:
|
||||||
|
name: Build iOS
|
||||||
|
needs: [prepare-versions]
|
||||||
|
if: inputs.platform == 'ios' || inputs.platform == 'both'
|
||||||
|
runs-on: macos-14
|
||||||
|
timeout-minutes: 90
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: apps/mobile
|
||||||
|
outputs:
|
||||||
|
artifact-path: ${{ steps.build.outputs.artifact-path }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1
|
||||||
|
with:
|
||||||
|
xcode-version: latest-stable
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: Build shared package
|
||||||
|
run: pnpm --filter @tracearr/shared build
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: Setup Expo
|
||||||
|
uses: expo/expo-github-action@main
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set versions from prepare job
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.prepare-versions.outputs.marketing-version }}"
|
||||||
|
BUILD_NUMBER="${{ needs.prepare-versions.outputs.ios-build-number }}"
|
||||||
|
|
||||||
|
echo "Setting version: $VERSION, buildNumber: $BUILD_NUMBER"
|
||||||
|
|
||||||
|
jq --arg v "$VERSION" \
|
||||||
|
--arg bn "$BUILD_NUMBER" \
|
||||||
|
'.expo.version = $v | .expo.ios.buildNumber = $bn' \
|
||||||
|
app.json > tmp.json && mv tmp.json app.json
|
||||||
|
|
||||||
|
- name: Build iOS locally
|
||||||
|
id: build
|
||||||
|
run: |
|
||||||
|
OUTPUT_FILE="tracearr-${{ inputs.tag }}-ios.ipa"
|
||||||
|
|
||||||
|
echo "Building iOS for ${{ inputs.profile }} profile..."
|
||||||
|
|
||||||
|
eas build \
|
||||||
|
--platform ios \
|
||||||
|
--profile ${{ inputs.profile }} \
|
||||||
|
--local \
|
||||||
|
--output "./${OUTPUT_FILE}" \
|
||||||
|
--non-interactive
|
||||||
|
|
||||||
|
echo "artifact-path=apps/mobile/${OUTPUT_FILE}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Build complete: ${OUTPUT_FILE}"
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ios-build
|
||||||
|
path: ${{ steps.build.outputs.artifact-path }}
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
upload-and-submit:
|
||||||
|
name: Upload and Submit
|
||||||
|
needs: [prepare-versions, build-android, build-ios]
|
||||||
|
if: always() && (needs.build-android.result == 'success' || needs.build-ios.result == 'success')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: apps/mobile
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: Setup Expo
|
||||||
|
uses: expo/expo-github-action@main
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Download Android artifact
|
||||||
|
if: needs.build-android.result == 'success'
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: android-build
|
||||||
|
path: apps/mobile/
|
||||||
|
|
||||||
|
- name: Download iOS artifact
|
||||||
|
if: needs.build-ios.result == 'success'
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ios-build
|
||||||
|
path: apps/mobile/
|
||||||
|
|
||||||
|
- name: Upload Android to EAS
|
||||||
|
if: needs.build-android.result == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
APK_FILE=$(ls *.apk 2>/dev/null || ls *.aab 2>/dev/null)
|
||||||
|
echo "Uploading $APK_FILE to EAS..."
|
||||||
|
eas upload --platform android --build-path "./${APK_FILE}" --non-interactive
|
||||||
|
|
||||||
|
- name: Upload iOS to EAS
|
||||||
|
if: needs.build-ios.result == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
IPA_FILE=$(ls *.ipa)
|
||||||
|
echo "Uploading $IPA_FILE to EAS..."
|
||||||
|
eas upload --platform ios --build-path "./${IPA_FILE}" --non-interactive
|
||||||
|
|
||||||
|
- name: Decode Google Service Account
|
||||||
|
if: inputs.distribution == 'store' && needs.build-android.result == 'success'
|
||||||
|
run: |
|
||||||
|
mkdir -p credentials
|
||||||
|
echo "${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}" | base64 -d > credentials/google-service-account.json
|
||||||
|
|
||||||
|
- name: Submit Android to Play Store
|
||||||
|
if: inputs.distribution == 'store' && needs.build-android.result == 'success'
|
||||||
|
run: |
|
||||||
|
AAB_FILE=$(ls *.aab)
|
||||||
|
echo "Submitting $AAB_FILE to Play Store..."
|
||||||
|
eas submit --platform android --path "./${AAB_FILE}" --profile ${{ inputs.profile }} --non-interactive
|
||||||
|
|
||||||
|
- name: Submit iOS to App Store
|
||||||
|
if: inputs.distribution == 'store' && needs.build-ios.result == 'success'
|
||||||
|
env:
|
||||||
|
EXPO_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.EXPO_APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
IPA_FILE=$(ls *.ipa)
|
||||||
|
echo "Submitting $IPA_FILE to App Store..."
|
||||||
|
eas submit --platform ios --path "./${IPA_FILE}" --profile ${{ inputs.profile }} --non-interactive
|
||||||
|
|
||||||
|
summary:
|
||||||
|
name: Build Summary
|
||||||
|
needs: [prepare-versions, build-android, build-ios, upload-and-submit]
|
||||||
|
if: always()
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Generate summary
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "## Mobile Build Summary"
|
||||||
|
echo ""
|
||||||
|
echo "**Tag**: ${{ inputs.tag }}"
|
||||||
|
echo "**Platform**: ${{ inputs.platform }}"
|
||||||
|
echo "**Distribution**: ${{ inputs.distribution }}"
|
||||||
|
echo "**Profile**: ${{ inputs.profile }}"
|
||||||
|
echo ""
|
||||||
|
echo "### Version Info"
|
||||||
|
echo "- **Marketing Version**: ${{ needs.prepare-versions.outputs.marketing-version }}"
|
||||||
|
echo "- **Android versionCode**: ${{ needs.prepare-versions.outputs.android-version-code }}"
|
||||||
|
echo "- **iOS buildNumber**: ${{ needs.prepare-versions.outputs.ios-build-number }}"
|
||||||
|
echo ""
|
||||||
|
echo "### Build Results"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ "${{ inputs.platform }}" == "android" || "${{ inputs.platform }}" == "both" ]]; then
|
||||||
|
if [[ "${{ needs.build-android.result }}" == "success" ]]; then
|
||||||
|
echo "[OK] Android: Built successfully"
|
||||||
|
else
|
||||||
|
echo "[FAIL] Android: ${{ needs.build-android.result }}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${{ inputs.platform }}" == "ios" || "${{ inputs.platform }}" == "both" ]]; then
|
||||||
|
if [[ "${{ needs.build-ios.result }}" == "success" ]]; then
|
||||||
|
echo "[OK] iOS: Built successfully"
|
||||||
|
else
|
||||||
|
echo "[FAIL] iOS: ${{ needs.build-ios.result }}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "### Upload and Submit"
|
||||||
|
if [[ "${{ needs.upload-and-submit.result }}" == "success" ]]; then
|
||||||
|
echo "[OK] Uploaded to EAS"
|
||||||
|
if [[ "${{ inputs.distribution }}" == "store" ]]; then
|
||||||
|
echo "[OK] Submitted to stores"
|
||||||
|
else
|
||||||
|
echo "[INFO] Internal distribution - check Expo dashboard for download links"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[WARN] Upload/Submit: ${{ needs.upload-and-submit.result }}"
|
||||||
|
fi
|
||||||
|
} >> $GITHUB_STEP_SUMMARY
|
||||||
314
.github/workflows/mobile-release.yml
vendored
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
name: Mobile Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Tag to deploy (e.g., v1.2.3-beta.1)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: mobile-release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Check if this is a prerelease (alpha, beta, rc)
|
||||||
|
check-release-type:
|
||||||
|
name: Check Release Type
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
prerelease: ${{ steps.check.outputs.prerelease }}
|
||||||
|
previous_tag: ${{ steps.prev_tag.outputs.tag }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: Check if prerelease
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
TAG="${{ inputs.tag }}"
|
||||||
|
if [[ "$TAG" == *-* ]]; then
|
||||||
|
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "This is a prerelease: $TAG"
|
||||||
|
else
|
||||||
|
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "This is a stable release: $TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Get previous tag
|
||||||
|
id: prev_tag
|
||||||
|
run: |
|
||||||
|
TAG="${{ inputs.tag }}"
|
||||||
|
PREV_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
|
||||||
|
echo "tag=$PREV_TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Previous tag: $PREV_TAG"
|
||||||
|
|
||||||
|
# Check if mobile code changed since last tag
|
||||||
|
check-mobile-changes:
|
||||||
|
name: Check Mobile Changes
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [check-release-type]
|
||||||
|
if: needs.check-release-type.outputs.prerelease == 'true'
|
||||||
|
outputs:
|
||||||
|
mobile_changed: ${{ steps.check.outputs.changed }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: Check for mobile changes
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
PREV_TAG="${{ needs.check-release-type.outputs.previous_tag }}"
|
||||||
|
CURRENT_TAG="${{ inputs.tag }}"
|
||||||
|
|
||||||
|
if [ -z "$PREV_TAG" ]; then
|
||||||
|
echo "No previous tag found, assuming mobile changed"
|
||||||
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if any files in apps/mobile or shared packages changed
|
||||||
|
CHANGES=$(git diff --name-only "$PREV_TAG" "$CURRENT_TAG" -- apps/mobile/ packages/shared/ | wc -l)
|
||||||
|
|
||||||
|
if [ "$CHANGES" -gt 0 ]; then
|
||||||
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Mobile changes detected: $CHANGES files"
|
||||||
|
git diff --name-only "$PREV_TAG" "$CURRENT_TAG" -- apps/mobile/ packages/shared/
|
||||||
|
else
|
||||||
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No mobile changes since $PREV_TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deploy mobile app using fingerprint-based detection
|
||||||
|
# This action automatically:
|
||||||
|
# 1. Calculates the native fingerprint
|
||||||
|
# 2. Checks if a compatible build already exists
|
||||||
|
# 3. If no build exists → triggers eas build
|
||||||
|
# 4. If build exists → reuses it
|
||||||
|
# 5. Pushes OTA update to the channel
|
||||||
|
deploy:
|
||||||
|
name: Deploy Mobile
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [check-release-type, check-mobile-changes]
|
||||||
|
if: |
|
||||||
|
needs.check-release-type.outputs.prerelease == 'true' &&
|
||||||
|
needs.check-mobile-changes.outputs.mobile_changed == 'true'
|
||||||
|
timeout-minutes: 60
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: apps/mobile
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: Build shared package
|
||||||
|
run: pnpm --filter @tracearr/shared build
|
||||||
|
|
||||||
|
- name: Setup Expo
|
||||||
|
uses: expo/expo-github-action@main
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Validate secrets
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
|
||||||
|
echo "::error::EXPO_TOKEN secret is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Sync version from git tag
|
||||||
|
run: |
|
||||||
|
# Extract version from tag (v1.2.3-alpha.4 -> 1.2.3)
|
||||||
|
VERSION="${{ inputs.tag }}"
|
||||||
|
VERSION="${VERSION#v}" # Remove 'v' prefix
|
||||||
|
VERSION="${VERSION%%-*}" # Remove prerelease suffix (-alpha.4, -beta.1, etc.)
|
||||||
|
echo "Setting app version to: $VERSION"
|
||||||
|
# Update app.json with the version
|
||||||
|
jq --arg v "$VERSION" '.expo.version = $v' app.json > tmp.json && mv tmp.json app.json
|
||||||
|
# Verify the change
|
||||||
|
ACTUAL=$(jq -r '.expo.version' app.json)
|
||||||
|
if [ "$ACTUAL" != "$VERSION" ]; then
|
||||||
|
echo "::error::Version sync failed. Expected $VERSION, got $ACTUAL"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Version synced: $ACTUAL"
|
||||||
|
|
||||||
|
- name: Decode Google Service Account
|
||||||
|
run: |
|
||||||
|
mkdir -p credentials
|
||||||
|
echo "${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}" | base64 -d > credentials/google-service-account.json
|
||||||
|
|
||||||
|
- name: Deploy with fingerprint
|
||||||
|
uses: expo/expo-github-action/continuous-deploy-fingerprint@main
|
||||||
|
id: deploy
|
||||||
|
with:
|
||||||
|
profile: preview
|
||||||
|
branch: preview
|
||||||
|
working-directory: apps/mobile
|
||||||
|
auto-submit-builds: true
|
||||||
|
env:
|
||||||
|
EXPO_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.EXPO_APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
|
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
||||||
|
|
||||||
|
- name: Output deployment info
|
||||||
|
run: |
|
||||||
|
echo "## Deployment Results" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "**Fingerprint**: \`${{ steps.deploy.outputs.fingerprint }}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "**Build needed**: ${{ steps.deploy.outputs.build-new-build }}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
if [ -n "${{ steps.deploy.outputs.ios-build-id }}" ]; then
|
||||||
|
echo "**iOS Build ID**: ${{ steps.deploy.outputs.ios-build-id }}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
|
if [ -n "${{ steps.deploy.outputs.android-build-id }}" ]; then
|
||||||
|
echo "**Android Build ID**: ${{ steps.deploy.outputs.android-build-id }}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
|
if [ -n "${{ steps.deploy.outputs.update-id }}" ]; then
|
||||||
|
echo "**Update ID**: ${{ steps.deploy.outputs.update-id }}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback: Manual OTA if fingerprint action has issues
|
||||||
|
# This job only runs if deploy fails and we want to try OTA anyway
|
||||||
|
ota-fallback:
|
||||||
|
name: OTA Fallback
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [check-release-type, check-mobile-changes, deploy]
|
||||||
|
if: |
|
||||||
|
always() &&
|
||||||
|
needs.deploy.result == 'failure' &&
|
||||||
|
needs.check-release-type.outputs.prerelease == 'true' &&
|
||||||
|
needs.check-mobile-changes.outputs.mobile_changed == 'true'
|
||||||
|
timeout-minutes: 15
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: apps/mobile
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: Build shared package
|
||||||
|
run: pnpm --filter @tracearr/shared build
|
||||||
|
|
||||||
|
- name: Setup Expo
|
||||||
|
uses: expo/expo-github-action@main
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Push OTA update with retry
|
||||||
|
run: |
|
||||||
|
TAG="${{ inputs.tag }}"
|
||||||
|
echo "::warning::Deploy failed, attempting OTA fallback for $TAG"
|
||||||
|
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
echo "Attempt $attempt of 3..."
|
||||||
|
if eas update \
|
||||||
|
--channel preview \
|
||||||
|
--message "Release $TAG (fallback)" \
|
||||||
|
--non-interactive; then
|
||||||
|
echo "OTA update successful on attempt $attempt"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ $attempt -lt 3 ]; then
|
||||||
|
echo "Attempt $attempt failed, waiting before retry..."
|
||||||
|
sleep $((30 * attempt))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "::error::OTA update failed after 3 attempts"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
# Summary job that reports status
|
||||||
|
summary:
|
||||||
|
name: Release Summary
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [check-release-type, check-mobile-changes, deploy, ota-fallback]
|
||||||
|
if: always()
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Report status
|
||||||
|
env:
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
PRERELEASE: ${{ needs.check-release-type.outputs.prerelease }}
|
||||||
|
MOBILE_CHANGED: ${{ needs.check-mobile-changes.outputs.mobile_changed || 'skipped' }}
|
||||||
|
DEPLOY_RESULT: ${{ needs.deploy.result }}
|
||||||
|
FALLBACK_RESULT: ${{ needs.ota-fallback.result }}
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "## Mobile Release Summary"
|
||||||
|
echo ""
|
||||||
|
echo "**Tag**: $TAG"
|
||||||
|
echo "**Prerelease**: $PRERELEASE"
|
||||||
|
echo "**Mobile Changed**: $MOBILE_CHANGED"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ "$PRERELEASE" != "true" ]]; then
|
||||||
|
echo "ℹ️ Skipped: Not a prerelease (stable releases don't trigger mobile builds)"
|
||||||
|
elif [[ "$MOBILE_CHANGED" != "true" ]]; then
|
||||||
|
echo "ℹ️ Skipped: No mobile code changes detected"
|
||||||
|
else
|
||||||
|
echo "### Deployment Status"
|
||||||
|
echo ""
|
||||||
|
if [[ "$DEPLOY_RESULT" == "success" ]]; then
|
||||||
|
echo "✅ **Deploy**: Success"
|
||||||
|
echo ""
|
||||||
|
echo "The fingerprint-based deployment completed successfully."
|
||||||
|
echo "- If native changes were detected, a new build was triggered"
|
||||||
|
echo "- If only JS changes, an OTA update was pushed"
|
||||||
|
echo ""
|
||||||
|
echo "Check the [Expo dashboard](https://expo.dev) for details."
|
||||||
|
elif [[ "$FALLBACK_RESULT" == "success" ]]; then
|
||||||
|
echo "⚠️ **Deploy**: Failed (fingerprint action issue)"
|
||||||
|
echo "✅ **OTA Fallback**: Success"
|
||||||
|
echo ""
|
||||||
|
echo "The main deploy failed but OTA fallback succeeded."
|
||||||
|
echo "Users will receive the JS update on next app launch."
|
||||||
|
else
|
||||||
|
echo "❌ **Deploy**: $DEPLOY_RESULT"
|
||||||
|
echo "❌ **OTA Fallback**: $FALLBACK_RESULT"
|
||||||
|
echo ""
|
||||||
|
echo "Both deployment methods failed. Check workflow logs for details."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
137
.github/workflows/nightly.yml
vendored
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
name: Nightly
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Run at 4am UTC daily
|
||||||
|
- cron: '0 4 * * *'
|
||||||
|
workflow_dispatch: # Allow manual trigger
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: nightly
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
# github.repository must be lowercased for Docker registry compatibility
|
||||||
|
IMAGE_NAME: ${{ github.repository_owner }}/tracearr
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-changes:
|
||||||
|
name: Check for changes
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
should_build: ${{ steps.check.outputs.should_build }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Check if new commits since last nightly
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
# Get commits in last 25 hours (buffer for timing)
|
||||||
|
COMMITS=$(git log --since="25 hours ago" --oneline | wc -l)
|
||||||
|
if [ "$COMMITS" -gt 0 ]; then
|
||||||
|
echo "should_build=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Found $COMMITS new commits"
|
||||||
|
else
|
||||||
|
echo "should_build=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No new commits, skipping build"
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-and-push:
|
||||||
|
name: Build & Push Docker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.should_build == 'true' || github.event_name == 'workflow_dispatch'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
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 Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=nightly
|
||||||
|
type=raw,value=nightly-{{date 'YYYYMMDD'}}
|
||||||
|
type=sha,prefix=sha-
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./docker/Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
build-and-push-supervised:
|
||||||
|
name: Build & Push Supervised Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.should_build == 'true' || github.event_name == 'workflow_dispatch'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
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 Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata for supervised image
|
||||||
|
id: meta-supervised
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=supervised-nightly
|
||||||
|
type=raw,value=supervised-nightly-{{date 'YYYYMMDD'}}
|
||||||
|
|
||||||
|
- name: Build and push supervised image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./docker/Dockerfile.supervised
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta-supervised.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta-supervised.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
455
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
# github.repository must be lowercased for Docker registry compatibility
|
||||||
|
IMAGE_NAME: ${{ github.repository_owner }}/tracearr
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Run the same checks as CI
|
||||||
|
lint-and-typecheck:
|
||||||
|
name: Lint & Typecheck
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
env:
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Cache Turborepo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .turbo
|
||||||
|
key: ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||||
|
${{ runner.os }}-turbo-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Test (${{ matrix.group }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
group: [unit, services, routes, security]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Cache Turborepo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .turbo
|
||||||
|
key: ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||||
|
${{ runner.os }}-turbo-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build dependencies (shared, test-utils)
|
||||||
|
run: pnpm turbo run build --filter=@tracearr/shared --filter=@tracearr/test-utils
|
||||||
|
|
||||||
|
- name: Run ${{ matrix.group }} tests
|
||||||
|
run: pnpm --filter @tracearr/server test:${{ matrix.group }}
|
||||||
|
|
||||||
|
# Integration tests require real database - run separately from unit tests
|
||||||
|
test-integration:
|
||||||
|
name: Test (integration)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
TEST_DATABASE_URL: postgresql://test:test@localhost:5433/tracearr_test
|
||||||
|
TEST_REDIS_URL: redis://localhost:6380
|
||||||
|
|
||||||
|
services:
|
||||||
|
timescale:
|
||||||
|
image: timescale/timescaledb:latest-pg15
|
||||||
|
ports:
|
||||||
|
- 5433:5432
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: test
|
||||||
|
POSTGRES_PASSWORD: test
|
||||||
|
POSTGRES_DB: tracearr_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U test -d tracearr_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 10
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- 6380:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Cache Turborepo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .turbo
|
||||||
|
key: ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||||
|
${{ runner.os }}-turbo-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build dependencies (shared, test-utils)
|
||||||
|
run: pnpm turbo run build --filter=@tracearr/shared --filter=@tracearr/test-utils
|
||||||
|
|
||||||
|
- name: Run integration tests
|
||||||
|
run: pnpm --filter @tracearr/server test:integration
|
||||||
|
|
||||||
|
# Determine if this is a prerelease (alpha, beta, rc, etc.)
|
||||||
|
check-release-type:
|
||||||
|
name: Check Release Type
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint-and-typecheck, test, test-integration]
|
||||||
|
outputs:
|
||||||
|
prerelease: ${{ steps.check.outputs.prerelease }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check if prerelease
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
TAG="${{ github.ref_name }}"
|
||||||
|
if [[ "$TAG" == *-* ]]; then
|
||||||
|
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "This is a prerelease: $TAG"
|
||||||
|
else
|
||||||
|
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "This is a stable release: $TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build each platform natively in parallel (much faster than QEMU emulation)
|
||||||
|
build-docker:
|
||||||
|
name: Build Docker (${{ matrix.platform }})
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
needs: [check-release-type]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
arch: amd64
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
arch: arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./docker/Dockerfile
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha,scope=docker-${{ matrix.arch }}
|
||||||
|
cache-to: type=gha,mode=max,scope=docker-${{ matrix.arch }}
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-docker-${{ matrix.arch }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
# Merge platform-specific images into multi-arch manifest
|
||||||
|
merge-docker:
|
||||||
|
name: Merge Docker Manifest
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [check-release-type, build-docker]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digests-docker-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}},enable=${{ needs.check-release-type.outputs.prerelease == 'false' }}
|
||||||
|
type=semver,pattern={{major}},enable=${{ needs.check-release-type.outputs.prerelease == 'false' }}
|
||||||
|
type=raw,value=next,enable=${{ needs.check-release-type.outputs.prerelease == 'true' }}
|
||||||
|
|
||||||
|
- name: Create manifest list and push
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
if [ -z "$(ls -A .)" ]; then
|
||||||
|
echo "Error: No digests found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker buildx imagetools create \
|
||||||
|
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Inspect image
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||||
|
|
||||||
|
# Build supervised image on each platform
|
||||||
|
build-supervised:
|
||||||
|
name: Build Supervised (${{ matrix.platform }})
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
needs: [check-release-type]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
arch: amd64
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
arch: arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./docker/Dockerfile.supervised
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha,scope=supervised-${{ matrix.arch }}
|
||||||
|
cache-to: type=gha,mode=max,scope=supervised-${{ matrix.arch }}
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-supervised-${{ matrix.arch }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
# Merge supervised multi-arch manifest
|
||||||
|
merge-supervised:
|
||||||
|
name: Merge Supervised Manifest
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [check-release-type, build-supervised]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digests-supervised-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
# Use flavor prefix for reliable tag prefixing
|
||||||
|
flavor: |
|
||||||
|
prefix=supervised-,onlatest=false
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=raw,value=supervised,enable=${{ needs.check-release-type.outputs.prerelease == 'false' }},prefix=
|
||||||
|
type=raw,value=supervised-next,enable=${{ needs.check-release-type.outputs.prerelease == 'true' }},prefix=
|
||||||
|
|
||||||
|
- name: Create manifest list and push
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
if [ -z "$(ls -A .)" ]; then
|
||||||
|
echo "Error: No digests found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker buildx imagetools create \
|
||||||
|
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Inspect image
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||||
|
|
||||||
|
create-release:
|
||||||
|
name: Create GitHub Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [check-release-type, merge-docker, merge-supervised]
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Get tag annotation
|
||||||
|
id: tag_info
|
||||||
|
run: |
|
||||||
|
# Get the tag message (annotation)
|
||||||
|
TAG_MESSAGE=$(git tag -l --format='%(contents)' ${{ github.ref_name }})
|
||||||
|
# Write to file to preserve newlines
|
||||||
|
echo "$TAG_MESSAGE" > tag_message.txt
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
name: ${{ github.ref_name }}
|
||||||
|
body_path: tag_message.txt
|
||||||
|
prerelease: ${{ needs.check-release-type.outputs.prerelease }}
|
||||||
|
generate_release_notes: false
|
||||||
56
.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Turbo
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Database (keep GeoLite2 database committed)
|
||||||
|
data/*.mmdb
|
||||||
|
!data/GeoLite2-City.mmdb
|
||||||
|
|
||||||
|
# Local server data
|
||||||
|
apps/server/data/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
*.orig.*
|
||||||
|
web-build/
|
||||||
|
|
||||||
|
# Drizzle
|
||||||
|
drizzle-kit
|
||||||
|
*.tsbuildinfo
|
||||||
1
.pnpmrc.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"onlyBuiltDependencies": ["bcrypt", "esbuild", "sharp"]}
|
||||||
7
.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.turbo
|
||||||
|
.next
|
||||||
|
coverage
|
||||||
|
pnpm-lock.yaml
|
||||||
11
.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
|
}
|
||||||
1
.tool-versions
Normal file
@@ -0,0 +1 @@
|
|||||||
|
nodejs 22.21.1
|
||||||
661
LICENSE
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
243
README.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="apps/web/public/images/banner.png" alt="Tracearr" width="600" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>Know who's streaming. Catch account sharers. Take back control.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/connorgallopo/Tracearr/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/connorgallopo/Tracearr/ci.yml?branch=main&style=flat-square&label=CI" alt="CI Status" /></a>
|
||||||
|
<a href="https://github.com/connorgallopo/Tracearr/actions/workflows/nightly.yml"><img src="https://img.shields.io/github/actions/workflow/status/connorgallopo/Tracearr/nightly.yml?style=flat-square&label=Nightly" alt="Nightly Build" /></a>
|
||||||
|
<a href="https://github.com/connorgallopo/Tracearr/releases"><img src="https://img.shields.io/github/v/release/connorgallopo/Tracearr?style=flat-square&color=18D1E7" alt="Latest Release" /></a>
|
||||||
|
<a href="https://ghcr.io/connorgallopo/tracearr"><img src="https://img.shields.io/badge/ghcr.io-tracearr-blue?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
|
||||||
|
<a href="https://github.com/connorgallopo/Tracearr/blob/main/LICENSE"><img src="https://img.shields.io/github/license/connorgallopo/Tracearr?style=flat-square" alt="License" /></a>
|
||||||
|
<a href="https://discord.gg/a7n3sFd2Yw"><img src="https://img.shields.io/discord/1444393247978946684?style=flat-square&logo=discord&logoColor=white&label=Discord&color=5865F2" alt="Discord" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Tracearr is a streaming access manager for **Plex**, **Jellyfin**, and **Emby** that answers one question: *Who's actually using my server, and are they sharing their login?*
|
||||||
|
|
||||||
|
Unlike monitoring tools that just show you data, Tracearr is built to detect account abuse. See streams in real-time, flag suspicious activity automatically, and get notified the moment something looks off.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
**Session Tracking** — Full history of who watched what, when, from where, on what device. Every stream logged with geolocation data.
|
||||||
|
|
||||||
|
**Sharing Detection** — Five rule types catch account sharers:
|
||||||
|
- 🚀 **Impossible Travel** — NYC then London 30 minutes later? That's not one person.
|
||||||
|
- 📍 **Simultaneous Locations** — Same account streaming from two cities at once.
|
||||||
|
- 🔀 **Device Velocity** — Too many unique IPs in a short window signals shared credentials.
|
||||||
|
- 📺 **Concurrent Streams** — Set limits per user. Simple but effective.
|
||||||
|
- 🌍 **Geo Restrictions** — Block streaming from specific countries entirely.
|
||||||
|
|
||||||
|
**Real-Time Alerts** — Discord webhooks and custom notifications fire instantly when rules trigger. No waiting for daily reports.
|
||||||
|
|
||||||
|
**Stream Map** — Visualize where your streams originate on an interactive world map. Filter by user, server, or time period to zero in on suspicious patterns.
|
||||||
|
|
||||||
|
**Trust Scores** — Users earn (or lose) trust based on their behavior. Violations drop scores automatically.
|
||||||
|
|
||||||
|
**Multi-Server** — Connect Plex, Jellyfin, and Emby instances to the same dashboard. Manage everything in one place.
|
||||||
|
|
||||||
|
**Tautulli Import** — Already using Tautulli? Import your watch history so you don't start from scratch.
|
||||||
|
|
||||||
|
## What It Doesn't Do (Yet)
|
||||||
|
|
||||||
|
Tracearr v1 is focused on **detection and alerting**. Automated enforcement—killing streams, suspending accounts—is coming in future versions. For now, you see the problems; you decide the action.
|
||||||
|
|
||||||
|
## Why Not Tautulli or Jellystat?
|
||||||
|
|
||||||
|
[Tautulli](https://github.com/Tautulli/Tautulli) and [Jellystat](https://github.com/CyferShepard/Jellystat) are great monitoring tools. We use Highcharts for graphs too. But they show you what happened—they don't tell you when something's wrong.
|
||||||
|
|
||||||
|
| | Tautulli | Jellystat | Tracearr |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Watch history | ✅ | ✅ | ✅ |
|
||||||
|
| Statistics & graphs | ✅ | ✅ | ✅ |
|
||||||
|
| Session monitoring | ✅ | ✅ | ✅ |
|
||||||
|
| Account sharing detection | ❌ | ❌ | ✅ |
|
||||||
|
| Impossible travel alerts | ❌ | ❌ | ✅ |
|
||||||
|
| Trust scoring | ❌ | ❌ | ✅ |
|
||||||
|
| Plex support | ✅ | ❌ | ✅ |
|
||||||
|
| Jellyfin support | ❌ | ✅ | ✅ |
|
||||||
|
| Emby support | ❌ | ✅ | ✅ |
|
||||||
|
| Multi-server dashboard | ❌ | ❌ | ✅ |
|
||||||
|
| IP geolocation | ✅ | ✅ | ✅ |
|
||||||
|
| Import from Tautulli | — | ❌ | ✅ |
|
||||||
|
|
||||||
|
Tautulli and Jellystat are platform-locked equivalents—Plex-only vs Jellyfin/Emby-only. If you just want stats, they work fine. If you're tired of your brother's roommate's cousin streaming on your dime, that's what Tracearr is for.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Option 1: All-in-One (Recommended)
|
||||||
|
|
||||||
|
The supervised image bundles TimescaleDB, Redis, and Tracearr in a single container. No external database required. Secrets are auto-generated on first run.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.supervised.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:3000` and connect your Plex, Jellyfin, or Emby server.
|
||||||
|
|
||||||
|
### Option 2: Separate Services
|
||||||
|
|
||||||
|
If you already have TimescaleDB/PostgreSQL and Redis, or prefer managing services separately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy and configure environment
|
||||||
|
cp docker/.env.example docker/.env
|
||||||
|
# Edit docker/.env with your secrets (generate with: openssl rand -hex 32)
|
||||||
|
|
||||||
|
docker compose -f docker/docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Tags
|
||||||
|
|
||||||
|
| Tag | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `latest` | Stable release (requires external DB/Redis) |
|
||||||
|
| `supervised` | All-in-one stable release |
|
||||||
|
| `next` | Latest prerelease |
|
||||||
|
| `supervised-nightly` | All-in-one nightly build |
|
||||||
|
| `nightly` | Bleeding edge nightly |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All-in-one (easiest)
|
||||||
|
docker pull ghcr.io/connorgallopo/tracearr:supervised
|
||||||
|
|
||||||
|
# Stable (requires external services)
|
||||||
|
docker pull ghcr.io/connorgallopo/tracearr:latest
|
||||||
|
|
||||||
|
# Living on the edge
|
||||||
|
docker pull ghcr.io/connorgallopo/tracearr:nightly
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies (requires pnpm 10+, Node.js 22+)
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Start database services
|
||||||
|
docker compose -f docker/docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# Copy and configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
pnpm --filter @tracearr/server db:migrate
|
||||||
|
|
||||||
|
# Start dev servers
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend runs at `localhost:5173`, API at `localhost:3000`.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Tech |
|
||||||
|
|---|---|
|
||||||
|
| Frontend | React 19, TypeScript, Tailwind, shadcn/ui |
|
||||||
|
| Charts | Highcharts |
|
||||||
|
| Maps | Leaflet |
|
||||||
|
| Backend | Node.js, Fastify |
|
||||||
|
| Database | TimescaleDB (PostgreSQL extension) |
|
||||||
|
| Cache | Redis |
|
||||||
|
| Real-time | Socket.io |
|
||||||
|
| Monorepo | pnpm + Turborepo |
|
||||||
|
|
||||||
|
**TimescaleDB** handles session history. Regular Postgres works fine until you have a year of watch data and your stats queries start taking forever. TimescaleDB is built for exactly this kind of time-series data—dashboard stats stay fast because they're pre-computed, not recalculated every page load.
|
||||||
|
|
||||||
|
**Fastify** over Express because it's measurably faster and the schema validation is nice.
|
||||||
|
|
||||||
|
**Plex SSE** — Plex servers stream session updates in real-time via Server-Sent Events. No polling delay, instant detection. Jellyfin and Emby still use polling (they don't support SSE), but Plex sessions appear the moment they start.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tracearr/
|
||||||
|
├── apps/
|
||||||
|
│ ├── web/ # React frontend
|
||||||
|
│ ├── server/ # Fastify backend
|
||||||
|
│ └── mobile/ # React Native app (coming soon)
|
||||||
|
├── packages/
|
||||||
|
│ └── shared/ # Types, schemas, constants
|
||||||
|
├── docker/ # Compose files
|
||||||
|
└── docs/ # Documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Tracearr uses environment variables for configuration. Key settings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://user:pass@localhost:5432/tracearr
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# Security
|
||||||
|
JWT_SECRET=your-secret-here
|
||||||
|
COOKIE_SECRET=your-cookie-secret-here
|
||||||
|
|
||||||
|
# GeoIP (optional, for location detection)
|
||||||
|
MAXMIND_LICENSE_KEY=your-maxmind-key
|
||||||
|
```
|
||||||
|
|
||||||
|
See `.env.example` for all options.
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
Got questions? Found a bug? Want to contribute?
|
||||||
|
|
||||||
|
[](https://discord.gg/a7n3sFd2Yw)
|
||||||
|
|
||||||
|
Or [open an issue](https://github.com/connorgallopo/Tracearr/issues) on GitHub.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions welcome. Please:
|
||||||
|
|
||||||
|
1. Fork the repo
|
||||||
|
2. Create a feature branch (`git checkout -b feature/thing`)
|
||||||
|
3. Make your changes
|
||||||
|
4. Run tests and linting (`pnpm test && pnpm lint`)
|
||||||
|
5. Open a PR
|
||||||
|
|
||||||
|
Check the [issues](https://github.com/connorgallopo/Tracearr/issues) for things to work on.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
**Alpha** (current — v0.1.x)
|
||||||
|
- [x] Multi-server Plex, Jellyfin, and Emby support
|
||||||
|
- [x] Session tracking with full history
|
||||||
|
- [x] 5 sharing detection rules
|
||||||
|
- [x] Real-time WebSocket updates
|
||||||
|
- [x] Plex SSE for instant session detection
|
||||||
|
- [x] Discord + webhook notifications
|
||||||
|
- [x] Interactive stream map
|
||||||
|
- [x] Trust scores
|
||||||
|
- [x] Tautulli history import
|
||||||
|
|
||||||
|
**v1.5** (next milestone)
|
||||||
|
- [ ] Mobile app (iOS & Android) — *in development*
|
||||||
|
- [ ] Stream termination (kill suspicious streams)
|
||||||
|
- [ ] Account suspension automation
|
||||||
|
- [ ] Email notifications
|
||||||
|
- [ ] Telegram notifier
|
||||||
|
|
||||||
|
**v2.0** (future)
|
||||||
|
- [ ] Tiered access controls
|
||||||
|
- [ ] Arr integration (Radarr/Sonarr)
|
||||||
|
- [ ] Multi-admin support
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[AGPL-3.0](LICENSE) — Open source with copyleft protection. If you modify Tracearr and offer it as a service, you share your changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<sub>Built because sharing is caring, but not when it's your server bill.</sub>
|
||||||
|
</p>
|
||||||
127
RELEASE_NOTES_1.2.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Tracearr v1.2 — First Stable Release
|
||||||
|
|
||||||
|
After months of alpha and beta testing, Tracearr is officially stable. Thanks to everyone who's been running the prereleases, reporting bugs, and helping shape this thing.
|
||||||
|
|
||||||
|
## What is Tracearr?
|
||||||
|
|
||||||
|
Tracearr answers one question: **Who's actually using your media server, and are they sharing their login?**
|
||||||
|
|
||||||
|
It's not just another stats dashboard. Tracearr is built specifically to detect account abuse across Plex, Jellyfin, and Emby servers — impossible travel, simultaneous locations, device velocity, you name it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
### All-in-One Docker Image
|
||||||
|
|
||||||
|
The new `supervised` image bundles everything you need — TimescaleDB, Redis, and Tracearr — in a single container. No external database setup required. Secrets are auto-generated on first run.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull ghcr.io/connorgallopo/tracearr:supervised
|
||||||
|
```
|
||||||
|
|
||||||
|
Available on [Unraid Community Apps](https://github.com/connorgallopo/tracearr-unraid-template) too.
|
||||||
|
|
||||||
|
### Mobile App
|
||||||
|
|
||||||
|
iOS and Android companion app with:
|
||||||
|
- Real-time session monitoring
|
||||||
|
- Push notifications with quiet hours
|
||||||
|
- Interactive stream map
|
||||||
|
- QR code pairing (no manual URL entry)
|
||||||
|
|
||||||
|
### Plex Server-Sent Events
|
||||||
|
|
||||||
|
Plex sessions appear instantly via SSE — no more polling delays. Fallback to polling if your server doesn't support it.
|
||||||
|
|
||||||
|
### Multi-Server Support
|
||||||
|
|
||||||
|
Connect all your Plex, Jellyfin, and Emby servers to one install. See everything in one place.
|
||||||
|
|
||||||
|
### Dark & Light Mode
|
||||||
|
|
||||||
|
Full theme support — switch between dark and light mode based on your preference or system settings.
|
||||||
|
|
||||||
|
### Custom Date Filtering
|
||||||
|
|
||||||
|
Filter stats and activity by custom date ranges. Pick any start and end date, not just the preset week/month/year options.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Sharing Detection
|
||||||
|
- **Impossible Travel** — Same account in NYC then London 30 minutes later? Flagged.
|
||||||
|
- **Simultaneous Locations** — Streaming from two cities at once? Caught.
|
||||||
|
- **Device Velocity** — Too many unique IPs in a short window? Suspicious.
|
||||||
|
- **Concurrent Streams** — Set per-user limits.
|
||||||
|
- **Geo Restrictions** — Block specific countries entirely.
|
||||||
|
|
||||||
|
### Session Tracking
|
||||||
|
- Full watch history with geolocation
|
||||||
|
- Device and player info
|
||||||
|
- Pause duration tracking
|
||||||
|
- Progress estimation via Plex SSE
|
||||||
|
|
||||||
|
### Trust Scores
|
||||||
|
- Users earn (or lose) trust based on behavior
|
||||||
|
- Violations automatically drop scores
|
||||||
|
- Visual trust indicators in the dashboard
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
- Discord webhooks
|
||||||
|
- Custom webhook endpoints
|
||||||
|
- Push notifications to mobile app
|
||||||
|
- Per-channel routing
|
||||||
|
- Quiet hours and rate limiting
|
||||||
|
|
||||||
|
### Import & Migration
|
||||||
|
- Tautulli history import — don't start from scratch
|
||||||
|
- Bring your existing watch data
|
||||||
|
|
||||||
|
### Stream Map
|
||||||
|
- Interactive world map showing stream origins
|
||||||
|
- Filter by user, server, or time period
|
||||||
|
- Spot geographic anomalies at a glance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Tags
|
||||||
|
|
||||||
|
| Tag | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `latest` | Stable release (requires external DB/Redis) |
|
||||||
|
| `supervised` | **All-in-one stable** — just works |
|
||||||
|
| `1.2.0` | This specific version |
|
||||||
|
| `supervised-1.2.0` | This specific version (all-in-one) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's New Since Beta
|
||||||
|
|
||||||
|
- **Supervised image improvements** — non-root user, log rotation, timezone support, boot loop fixes
|
||||||
|
- **Better Unraid support** — auto-creates directories, handles corrupt data gracefully
|
||||||
|
- **Removed dead code** — cleaned up unused TimescaleDB aggregates
|
||||||
|
- **Mobile navigation** — proper tab navigation after first-time pairing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Next
|
||||||
|
|
||||||
|
- Stream termination (kill suspicious sessions remotely)
|
||||||
|
- Account suspension automation
|
||||||
|
- Email and Telegram notifications
|
||||||
|
- Multi-admin support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [Documentation](https://github.com/connorgallopo/Tracearr#readme)
|
||||||
|
- [Discord](https://discord.gg/a7n3sFd2Yw)
|
||||||
|
- [Report Issues](https://github.com/connorgallopo/Tracearr/issues)
|
||||||
|
- Mobile apps coming to App Store and Play Store
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Built because sharing is caring — but not when it's your server bill.
|
||||||
8
apps/mobile/.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Tracearr Mobile Environment Variables
|
||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
|
||||||
|
# Your Tracearr server URL (set during QR pairing, but can override for development)
|
||||||
|
# EXPO_PUBLIC_API_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# EAS Project ID (from expo.dev)
|
||||||
|
EXPO_PUBLIC_PROJECT_ID=your-eas-project-id
|
||||||
5
apps/mobile/.eslintignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
babel.config.js
|
||||||
|
metro.config.js
|
||||||
|
postcss.config.mjs
|
||||||
|
.expo/
|
||||||
|
plugins/
|
||||||
45
apps/mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# credentials (generated in CI)
|
||||||
|
credentials/
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
34
apps/mobile/app.config.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Dynamic Expo config that extends app.json
|
||||||
|
* Allows injecting secrets from environment variables at build time
|
||||||
|
*/
|
||||||
|
|
||||||
|
const baseConfig = require('./app.json');
|
||||||
|
|
||||||
|
module.exports = ({ config }) => {
|
||||||
|
// Merge base config with dynamic values
|
||||||
|
return {
|
||||||
|
...baseConfig.expo,
|
||||||
|
...config,
|
||||||
|
plugins: [
|
||||||
|
// Keep existing plugins but update expo-maps with API key from env
|
||||||
|
...baseConfig.expo.plugins.map((plugin) => {
|
||||||
|
// Handle expo-maps plugin
|
||||||
|
if (Array.isArray(plugin) && plugin[0] === 'expo-maps') {
|
||||||
|
return [
|
||||||
|
'expo-maps',
|
||||||
|
{
|
||||||
|
...plugin[1],
|
||||||
|
android: {
|
||||||
|
...plugin[1]?.android,
|
||||||
|
// Inject Google Maps API key from EAS secrets or env var
|
||||||
|
googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY || '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return plugin;
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
102
apps/mobile/app.json
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Tracearr",
|
||||||
|
"slug": "tracearr",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"scheme": "tracearr",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash-icon-transparent.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#051723"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.tracearr.app",
|
||||||
|
"infoPlist": {
|
||||||
|
"UIBackgroundModes": [
|
||||||
|
"remote-notification",
|
||||||
|
"fetch"
|
||||||
|
],
|
||||||
|
"NSCameraUsageDescription": "Tracearr needs camera access to scan QR codes for server pairing.",
|
||||||
|
"NSLocationWhenInUseUsageDescription": "Location permission is not required for this app.",
|
||||||
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
|
"NSAppTransportSecurity": {
|
||||||
|
"NSAllowsArbitraryLoads": true,
|
||||||
|
"NSAllowsLocalNetworking": true
|
||||||
|
},
|
||||||
|
"NSLocalNetworkUsageDescription": "Tracearr needs local network access to connect to your self-hosted server."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon-transparent.png",
|
||||||
|
"backgroundColor": "#051723"
|
||||||
|
},
|
||||||
|
"edgeToEdgeEnabled": true,
|
||||||
|
"package": "com.tracearr.app",
|
||||||
|
"permissions": [
|
||||||
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
|
"VIBRATE",
|
||||||
|
"android.permission.CAMERA",
|
||||||
|
"android.permission.POST_NOTIFICATIONS"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"./plugins/withGradleProperties",
|
||||||
|
{
|
||||||
|
"org.gradle.jvmargs": "-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8",
|
||||||
|
"org.gradle.daemon": "false"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-notifications",
|
||||||
|
{
|
||||||
|
"icon": "./assets/notification-icon.png",
|
||||||
|
"color": "#18D1E7",
|
||||||
|
"defaultChannel": "violations",
|
||||||
|
"enableBackgroundRemoteNotifications": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "Allow Tracearr to access your camera for QR code scanning.",
|
||||||
|
"recordAudioAndroid": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-maps",
|
||||||
|
{
|
||||||
|
"requestLocationPermission": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-secure-store",
|
||||||
|
"react-native-quick-crypto"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {
|
||||||
|
"origin": false
|
||||||
|
},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "2e0b9595-ac62-493f-9a10-4f8758bb4b2d"
|
||||||
|
},
|
||||||
|
"buildNumber": "1"
|
||||||
|
},
|
||||||
|
"owner": "gallopo-solutions",
|
||||||
|
"runtimeVersion": {
|
||||||
|
"policy": "fingerprint"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"url": "https://u.expo.dev/2e0b9595-ac62-493f-9a10-4f8758bb4b2d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/mobile/app/(auth)/_layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Auth layout - for login/pairing screens
|
||||||
|
*/
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: colors.background.dark },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="pair" options={{ title: 'Connect to Server' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
408
apps/mobile/app/(auth)/pair.tsx
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* Pairing screen - QR code scanner or manual entry
|
||||||
|
* Supports both initial pairing and adding additional servers
|
||||||
|
*/
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
} from 'react-native';
|
||||||
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { ChevronLeft } from 'lucide-react-native';
|
||||||
|
import { useAuthStore } from '@/lib/authStore';
|
||||||
|
import { colors, spacing, borderRadius, typography } from '@/lib/theme';
|
||||||
|
|
||||||
|
interface QRPairingPayload {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PairScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
const [manualMode, setManualMode] = useState(false);
|
||||||
|
const [serverUrl, setServerUrl] = useState('');
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [scanned, setScanned] = useState(false);
|
||||||
|
const scanLockRef = useRef(false); // Synchronous lock to prevent race conditions
|
||||||
|
|
||||||
|
const { addServer, isAuthenticated, servers, isLoading, error, clearError } = useAuthStore();
|
||||||
|
|
||||||
|
// Check if this is adding an additional server vs first-time pairing
|
||||||
|
const isAddingServer = isAuthenticated && servers.length > 0;
|
||||||
|
|
||||||
|
const handleBarCodeScanned = async ({ data }: { data: string }) => {
|
||||||
|
// Use ref for synchronous check - state updates are async and cause race conditions
|
||||||
|
if (scanLockRef.current || isLoading) return;
|
||||||
|
scanLockRef.current = true; // Immediate synchronous lock
|
||||||
|
setScanned(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse tracearr://pair?data=<base64>
|
||||||
|
// First check if it even looks like our URL format
|
||||||
|
if (!data.startsWith('tracearr://pair')) {
|
||||||
|
// Silently ignore non-Tracearr QR codes (don't spam alerts)
|
||||||
|
setTimeout(() => {
|
||||||
|
scanLockRef.current = false;
|
||||||
|
setScanned(false);
|
||||||
|
}, 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(data);
|
||||||
|
const base64Data = url.searchParams.get('data');
|
||||||
|
if (!base64Data) {
|
||||||
|
throw new Error('Invalid QR code: missing pairing data');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode and parse payload with proper error handling
|
||||||
|
let payload: QRPairingPayload;
|
||||||
|
try {
|
||||||
|
const decoded = atob(base64Data);
|
||||||
|
payload = JSON.parse(decoded) as QRPairingPayload;
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid QR code format. Please generate a new code.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate payload has required fields
|
||||||
|
if (!payload.url || typeof payload.url !== 'string') {
|
||||||
|
throw new Error('Invalid QR code: missing server URL');
|
||||||
|
}
|
||||||
|
if (!payload.token || typeof payload.token !== 'string') {
|
||||||
|
throw new Error('Invalid QR code: missing pairing token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL format
|
||||||
|
if (!payload.url.startsWith('http://') && !payload.url.startsWith('https://')) {
|
||||||
|
throw new Error('Invalid server URL in QR code');
|
||||||
|
}
|
||||||
|
|
||||||
|
await addServer(payload.url, payload.token);
|
||||||
|
|
||||||
|
// Navigate to tabs after successful pairing
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert('Pairing Failed', err instanceof Error ? err.message : 'Invalid QR code');
|
||||||
|
// Add cooldown before allowing another scan
|
||||||
|
setTimeout(() => {
|
||||||
|
scanLockRef.current = false;
|
||||||
|
setScanned(false);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualPair = async () => {
|
||||||
|
if (!serverUrl.trim() || !token.trim()) {
|
||||||
|
Alert.alert('Missing Fields', 'Please enter both server URL and token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedUrl = serverUrl.trim();
|
||||||
|
|
||||||
|
// Validate URL format
|
||||||
|
if (!trimmedUrl.startsWith('http://') && !trimmedUrl.startsWith('https://')) {
|
||||||
|
Alert.alert('Invalid URL', 'Server URL must start with http:// or https://');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL is well-formed
|
||||||
|
try {
|
||||||
|
new URL(trimmedUrl);
|
||||||
|
} catch {
|
||||||
|
Alert.alert('Invalid URL', 'Please enter a valid server URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
await addServer(trimmedUrl, token.trim());
|
||||||
|
|
||||||
|
// Navigate to tabs after successful pairing
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
} catch {
|
||||||
|
// Error is handled by the store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (manualMode) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
style={styles.keyboardView}
|
||||||
|
>
|
||||||
|
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||||
|
{/* Back button for adding servers */}
|
||||||
|
{isAddingServer && (
|
||||||
|
<Pressable style={styles.backButton} onPress={handleBack}>
|
||||||
|
<ChevronLeft size={24} color={colors.text.primary.dark} />
|
||||||
|
<Text style={styles.backText}>Back</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>
|
||||||
|
{isAddingServer ? 'Add Server' : 'Connect to Server'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Enter your Tracearr server URL and mobile access token
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>Server URL</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={serverUrl}
|
||||||
|
onChangeText={setServerUrl}
|
||||||
|
placeholder="https://tracearr.example.com"
|
||||||
|
placeholderTextColor={colors.text.muted.dark}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
editable={!isLoading}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>Access Token</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={token}
|
||||||
|
onChangeText={setToken}
|
||||||
|
placeholder="trr_mob_..."
|
||||||
|
placeholderTextColor={colors.text.muted.dark}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
secureTextEntry
|
||||||
|
editable={!isLoading}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||||
|
onPress={handleManualPair}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>
|
||||||
|
{isLoading ? 'Connecting...' : 'Connect'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={styles.linkButton}
|
||||||
|
onPress={() => setManualMode(false)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Text style={styles.linkText}>Scan QR Code Instead</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
|
{/* Back button for adding servers */}
|
||||||
|
{isAddingServer && (
|
||||||
|
<Pressable style={styles.backButton} onPress={handleBack}>
|
||||||
|
<ChevronLeft size={24} color={colors.text.primary.dark} />
|
||||||
|
<Text style={styles.backText}>Back</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>
|
||||||
|
{isAddingServer ? 'Add Server' : 'Welcome to Tracearr'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Open Settings → Mobile App in your Tracearr dashboard and scan the QR code
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cameraContainer}>
|
||||||
|
{permission?.granted ? (
|
||||||
|
<CameraView
|
||||||
|
style={styles.camera}
|
||||||
|
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||||
|
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<View style={styles.scanFrame} />
|
||||||
|
</View>
|
||||||
|
</CameraView>
|
||||||
|
) : (
|
||||||
|
<View style={styles.permissionContainer}>
|
||||||
|
<Text style={styles.permissionText}>
|
||||||
|
Camera permission is required to scan QR codes
|
||||||
|
</Text>
|
||||||
|
<Pressable style={styles.button} onPress={requestPermission}>
|
||||||
|
<Text style={styles.buttonText}>Grant Permission</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Pressable style={styles.linkButton} onPress={() => setManualMode(true)}>
|
||||||
|
<Text style={styles.linkText}>Enter URL and Token Manually</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background.dark,
|
||||||
|
},
|
||||||
|
keyboardView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
backText: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.primary.dark,
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingTop: spacing.xl,
|
||||||
|
paddingBottom: spacing.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: typography.fontSize['2xl'],
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.text.primary.dark,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.secondary.dark,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
cameraContainer: {
|
||||||
|
flex: 1,
|
||||||
|
marginHorizontal: spacing.lg,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
borderRadius: borderRadius.xl,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: colors.card.dark,
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||||
|
},
|
||||||
|
scanFrame: {
|
||||||
|
width: 250,
|
||||||
|
height: 250,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.cyan.core,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
permissionContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
permissionText: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.secondary.dark,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingBottom: spacing.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
flex: 1,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
inputGroup: {
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: colors.text.secondary.dark,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: colors.card.dark,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border.dark,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.primary.dark,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: colors.cyan.core,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.blue.core,
|
||||||
|
},
|
||||||
|
linkButton: {
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.cyan.core,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.error,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
116
apps/mobile/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Main tab navigation layout
|
||||||
|
*/
|
||||||
|
import { Tabs } from 'expo-router';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Activity,
|
||||||
|
Users,
|
||||||
|
Bell,
|
||||||
|
Settings,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
import { ServerSelector } from '@/components/ServerSelector';
|
||||||
|
|
||||||
|
interface TabIconProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
focused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabIcon({ icon: Icon, focused }: TabIconProps) {
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
size={24}
|
||||||
|
color={focused ? colors.cyan.core : colors.text.muted.dark}
|
||||||
|
strokeWidth={focused ? 2.5 : 2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
// Dynamic tab bar height: base height + safe area bottom inset
|
||||||
|
const tabBarHeight = 60 + insets.bottom;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: true,
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: colors.background.dark,
|
||||||
|
},
|
||||||
|
headerTintColor: colors.text.primary.dark,
|
||||||
|
headerTitleStyle: {
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: colors.card.dark,
|
||||||
|
borderTopColor: colors.border.dark,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
height: tabBarHeight,
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
tabBarActiveTintColor: colors.cyan.core,
|
||||||
|
tabBarInactiveTintColor: colors.text.muted.dark,
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: 'Dashboard',
|
||||||
|
headerTitle: () => <ServerSelector />,
|
||||||
|
tabBarLabel: 'Dashboard',
|
||||||
|
tabBarIcon: ({ focused }) => (
|
||||||
|
<TabIcon icon={LayoutDashboard} focused={focused} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="activity"
|
||||||
|
options={{
|
||||||
|
title: 'Activity',
|
||||||
|
headerTitle: () => <ServerSelector />,
|
||||||
|
tabBarLabel: 'Activity',
|
||||||
|
tabBarIcon: ({ focused }) => (
|
||||||
|
<TabIcon icon={Activity} focused={focused} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="users"
|
||||||
|
options={{
|
||||||
|
title: 'Users',
|
||||||
|
headerTitle: () => <ServerSelector />,
|
||||||
|
tabBarLabel: 'Users',
|
||||||
|
tabBarIcon: ({ focused }) => <TabIcon icon={Users} focused={focused} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="alerts"
|
||||||
|
options={{
|
||||||
|
title: 'Alerts',
|
||||||
|
headerTitle: () => <ServerSelector />,
|
||||||
|
tabBarLabel: 'Alerts',
|
||||||
|
tabBarIcon: ({ focused }) => <TabIcon icon={Bell} focused={focused} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="settings"
|
||||||
|
options={{
|
||||||
|
title: 'Settings',
|
||||||
|
tabBarLabel: 'Settings',
|
||||||
|
tabBarIcon: ({ focused }) => (
|
||||||
|
<TabIcon icon={Settings} focused={focused} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
apps/mobile/app/(tabs)/activity.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Activity tab - streaming statistics and charts
|
||||||
|
* Query keys include selectedServerId for proper cache isolation per media server
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { View, ScrollView, RefreshControl } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useMediaServer } from '@/providers/MediaServerProvider';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
import { Text } from '@/components/ui/text';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { PeriodSelector, type StatsPeriod } from '@/components/ui/period-selector';
|
||||||
|
import {
|
||||||
|
PlaysChart,
|
||||||
|
PlatformChart,
|
||||||
|
DayOfWeekChart,
|
||||||
|
HourOfDayChart,
|
||||||
|
QualityChart,
|
||||||
|
} from '@/components/charts';
|
||||||
|
|
||||||
|
function ChartSection({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityScreen() {
|
||||||
|
const [period, setPeriod] = useState<StatsPeriod>('month');
|
||||||
|
const { selectedServerId } = useMediaServer();
|
||||||
|
|
||||||
|
// Fetch all stats data with selected period - query keys include selectedServerId for cache isolation
|
||||||
|
const {
|
||||||
|
data: playsData,
|
||||||
|
refetch: refetchPlays,
|
||||||
|
isRefetching: isRefetchingPlays,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['stats', 'plays', period, selectedServerId],
|
||||||
|
queryFn: () => api.stats.plays({ period, serverId: selectedServerId ?? undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: dayOfWeekData, refetch: refetchDayOfWeek } = useQuery({
|
||||||
|
queryKey: ['stats', 'dayOfWeek', period, selectedServerId],
|
||||||
|
queryFn: () => api.stats.playsByDayOfWeek({ period, serverId: selectedServerId ?? undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: hourOfDayData, refetch: refetchHourOfDay } = useQuery({
|
||||||
|
queryKey: ['stats', 'hourOfDay', period, selectedServerId],
|
||||||
|
queryFn: () => api.stats.playsByHourOfDay({ period, serverId: selectedServerId ?? undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: platformsData, refetch: refetchPlatforms } = useQuery({
|
||||||
|
queryKey: ['stats', 'platforms', period, selectedServerId],
|
||||||
|
queryFn: () => api.stats.platforms({ period, serverId: selectedServerId ?? undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: qualityData, refetch: refetchQuality } = useQuery({
|
||||||
|
queryKey: ['stats', 'quality', period, selectedServerId],
|
||||||
|
queryFn: () => api.stats.quality({ period, serverId: selectedServerId ?? undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
void refetchPlays();
|
||||||
|
void refetchDayOfWeek();
|
||||||
|
void refetchHourOfDay();
|
||||||
|
void refetchPlatforms();
|
||||||
|
void refetchQuality();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Period labels for display
|
||||||
|
const periodLabels: Record<StatsPeriod, string> = {
|
||||||
|
week: 'Last 7 Days',
|
||||||
|
month: 'Last 30 Days',
|
||||||
|
year: 'Last Year',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerClassName="p-4 pt-3"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefetchingPlays}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
tintColor={colors.cyan.core}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Header with Period Selector */}
|
||||||
|
<View className="flex-row items-center justify-between mb-4">
|
||||||
|
<View>
|
||||||
|
<Text className="text-lg font-semibold">Activity</Text>
|
||||||
|
<Text className="text-sm text-muted-foreground">{periodLabels[period]}</Text>
|
||||||
|
</View>
|
||||||
|
<PeriodSelector value={period} onChange={setPeriod} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Plays Over Time */}
|
||||||
|
<ChartSection title="Plays Over Time">
|
||||||
|
<PlaysChart data={playsData?.data || []} height={180} />
|
||||||
|
</ChartSection>
|
||||||
|
|
||||||
|
{/* Day of Week & Hour of Day in a row on larger screens */}
|
||||||
|
<View className="flex-row gap-3 mb-4">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
By Day
|
||||||
|
</Text>
|
||||||
|
<DayOfWeekChart data={dayOfWeekData?.data || []} height={160} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
By Hour
|
||||||
|
</Text>
|
||||||
|
<HourOfDayChart data={hourOfDayData?.data || []} height={160} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Platform Breakdown */}
|
||||||
|
<ChartSection title="Platforms">
|
||||||
|
<PlatformChart data={platformsData?.data || []} />
|
||||||
|
</ChartSection>
|
||||||
|
|
||||||
|
{/* Quality Breakdown */}
|
||||||
|
<ChartSection title="Playback Quality">
|
||||||
|
{qualityData ? (
|
||||||
|
<QualityChart
|
||||||
|
directPlay={qualityData.directPlay}
|
||||||
|
transcode={qualityData.transcode}
|
||||||
|
directPlayPercent={qualityData.directPlayPercent}
|
||||||
|
transcodePercent={qualityData.transcodePercent}
|
||||||
|
height={120}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card className="h-[120px] items-center justify-center">
|
||||||
|
<Text className="text-muted-foreground">Loading...</Text>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</ChartSection>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
326
apps/mobile/app/(tabs)/alerts.tsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
/**
|
||||||
|
* Alerts tab - violations with infinite scroll
|
||||||
|
* Query keys include selectedServerId for proper cache isolation per media server
|
||||||
|
*/
|
||||||
|
import { View, FlatList, RefreshControl, Pressable, ActivityIndicator } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useInfiniteQuery, useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import {
|
||||||
|
MapPin,
|
||||||
|
Users,
|
||||||
|
Zap,
|
||||||
|
Monitor,
|
||||||
|
Globe,
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react-native';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useMediaServer } from '@/providers/MediaServerProvider';
|
||||||
|
import { Text } from '@/components/ui/text';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
import type { ViolationWithDetails, RuleType, UnitSystem } from '@tracearr/shared';
|
||||||
|
import { formatSpeed } from '@tracearr/shared';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
// Rule type icons mapping
|
||||||
|
const ruleIcons: Record<RuleType, LucideIcon> = {
|
||||||
|
impossible_travel: MapPin,
|
||||||
|
simultaneous_locations: Users,
|
||||||
|
device_velocity: Zap,
|
||||||
|
concurrent_streams: Monitor,
|
||||||
|
geo_restriction: Globe,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rule type display names
|
||||||
|
const ruleLabels: Record<RuleType, string> = {
|
||||||
|
impossible_travel: 'Impossible Travel',
|
||||||
|
simultaneous_locations: 'Simultaneous Locations',
|
||||||
|
device_velocity: 'Device Velocity',
|
||||||
|
concurrent_streams: 'Concurrent Streams',
|
||||||
|
geo_restriction: 'Geo Restriction',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format violation data into readable description based on rule type
|
||||||
|
function getViolationDescription(violation: ViolationWithDetails, unitSystem: UnitSystem = 'metric'): string {
|
||||||
|
const data = violation.data;
|
||||||
|
const ruleType = violation.rule?.type;
|
||||||
|
|
||||||
|
if (!data || !ruleType) {
|
||||||
|
return 'Rule violation detected';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (ruleType) {
|
||||||
|
case 'impossible_travel': {
|
||||||
|
const from = data.fromCity || data.fromLocation || 'unknown location';
|
||||||
|
const to = data.toCity || data.toLocation || 'unknown location';
|
||||||
|
const speed = typeof data.calculatedSpeedKmh === 'number'
|
||||||
|
? formatSpeed(data.calculatedSpeedKmh, unitSystem)
|
||||||
|
: 'impossible speed';
|
||||||
|
return `Traveled from ${from} to ${to} at ${speed}`;
|
||||||
|
}
|
||||||
|
case 'simultaneous_locations': {
|
||||||
|
const locations = data.locations as string[] | undefined;
|
||||||
|
const count = data.locationCount as number | undefined;
|
||||||
|
if (locations && locations.length > 0) {
|
||||||
|
return `Active from ${locations.length} locations: ${locations.slice(0, 2).join(', ')}${locations.length > 2 ? '...' : ''}`;
|
||||||
|
}
|
||||||
|
if (count) {
|
||||||
|
return `Streaming from ${count} different locations simultaneously`;
|
||||||
|
}
|
||||||
|
return 'Streaming from multiple locations simultaneously';
|
||||||
|
}
|
||||||
|
case 'device_velocity': {
|
||||||
|
const ipCount = data.ipCount as number | undefined;
|
||||||
|
const windowHours = data.windowHours as number | undefined;
|
||||||
|
if (ipCount && windowHours) {
|
||||||
|
return `${ipCount} different IPs used in ${windowHours}h window`;
|
||||||
|
}
|
||||||
|
return 'Too many unique devices in short period';
|
||||||
|
}
|
||||||
|
case 'concurrent_streams': {
|
||||||
|
const streamCount = data.streamCount as number | undefined;
|
||||||
|
const maxStreams = data.maxStreams as number | undefined;
|
||||||
|
if (streamCount && maxStreams) {
|
||||||
|
return `${streamCount} concurrent streams (limit: ${maxStreams})`;
|
||||||
|
}
|
||||||
|
return 'Exceeded concurrent stream limit';
|
||||||
|
}
|
||||||
|
case 'geo_restriction': {
|
||||||
|
const country = data.country as string | undefined;
|
||||||
|
const blockedCountry = data.blockedCountry as string | undefined;
|
||||||
|
if (country || blockedCountry) {
|
||||||
|
return `Streaming from blocked region: ${country || blockedCountry}`;
|
||||||
|
}
|
||||||
|
return 'Streaming from restricted location';
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 'Rule violation detected';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeverityBadge({ severity }: { severity: string }) {
|
||||||
|
const variant =
|
||||||
|
severity === 'critical' || severity === 'high'
|
||||||
|
? 'destructive'
|
||||||
|
: severity === 'warning'
|
||||||
|
? 'warning'
|
||||||
|
: 'default';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant={variant} className="capitalize">
|
||||||
|
{severity}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RuleIcon({ ruleType }: { ruleType: RuleType | undefined }) {
|
||||||
|
const IconComponent = ruleType ? ruleIcons[ruleType] : AlertTriangle;
|
||||||
|
return (
|
||||||
|
<View className="w-8 h-8 rounded-lg bg-surface items-center justify-center">
|
||||||
|
<IconComponent size={16} color={colors.cyan.core} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViolationCard({
|
||||||
|
violation,
|
||||||
|
onAcknowledge,
|
||||||
|
onPress,
|
||||||
|
unitSystem,
|
||||||
|
}: {
|
||||||
|
violation: ViolationWithDetails;
|
||||||
|
onAcknowledge: () => void;
|
||||||
|
onPress: () => void;
|
||||||
|
unitSystem: UnitSystem;
|
||||||
|
}) {
|
||||||
|
const username = violation.user?.username || 'Unknown User';
|
||||||
|
const ruleType = violation.rule?.type as RuleType | undefined;
|
||||||
|
const ruleName = ruleType ? ruleLabels[ruleType] : violation.rule?.name || 'Unknown Rule';
|
||||||
|
const description = getViolationDescription(violation, unitSystem);
|
||||||
|
const timeAgo = formatDistanceToNow(new Date(violation.createdAt), { addSuffix: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable onPress={onPress} className="active:opacity-80">
|
||||||
|
<Card className="mb-3">
|
||||||
|
{/* Header: User + Severity */}
|
||||||
|
<View className="flex-row justify-between items-start mb-3">
|
||||||
|
<Pressable
|
||||||
|
className="flex-row items-center gap-2.5 flex-1 active:opacity-70"
|
||||||
|
onPress={onPress}
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
thumbUrl={violation.user?.thumbUrl}
|
||||||
|
username={username}
|
||||||
|
size={40}
|
||||||
|
/>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-base font-semibold">{username}</Text>
|
||||||
|
<Text className="text-xs text-muted-foreground">{timeAgo}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<SeverityBadge severity={violation.severity} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content: Rule Type with Icon + Description */}
|
||||||
|
<View className="flex-row items-start gap-3 mb-3">
|
||||||
|
<RuleIcon ruleType={ruleType} />
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-medium text-cyan-core mb-1">
|
||||||
|
{ruleName}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm text-secondary leading-5" numberOfLines={2}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
{!violation.acknowledgedAt ? (
|
||||||
|
<Pressable
|
||||||
|
className="flex-row items-center justify-center gap-2 bg-cyan-core/15 py-2.5 rounded-lg active:opacity-70"
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAcknowledge();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check size={16} color={colors.cyan.core} />
|
||||||
|
<Text className="text-sm font-semibold text-cyan-core">Acknowledge</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<View className="flex-row items-center justify-center gap-2 bg-success/10 py-2.5 rounded-lg">
|
||||||
|
<Check size={16} color={colors.success} />
|
||||||
|
<Text className="text-sm text-success">Acknowledged</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertsScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { selectedServerId } = useMediaServer();
|
||||||
|
|
||||||
|
// Fetch settings for unit system preference
|
||||||
|
const { data: settings } = useQuery({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: api.settings.get,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
});
|
||||||
|
const unitSystem = settings?.unitSystem ?? 'metric';
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
refetch,
|
||||||
|
isRefetching,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['violations', selectedServerId],
|
||||||
|
queryFn: ({ pageParam = 1 }) =>
|
||||||
|
api.violations.list({ page: pageParam, pageSize: PAGE_SIZE, serverId: selectedServerId ?? undefined }),
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage: { page: number; totalPages: number }) => {
|
||||||
|
if (lastPage.page < lastPage.totalPages) {
|
||||||
|
return lastPage.page + 1;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const acknowledgeMutation = useMutation({
|
||||||
|
mutationFn: api.violations.acknowledge,
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['violations', selectedServerId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flatten all pages into single array
|
||||||
|
const violations = data?.pages.flatMap((page) => page.data) || [];
|
||||||
|
const unacknowledgedCount = violations.filter((v) => !v.acknowledgedAt).length;
|
||||||
|
const total = data?.pages[0]?.total || 0;
|
||||||
|
|
||||||
|
const handleEndReached = () => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
void fetchNextPage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViolationPress = (violation: ViolationWithDetails) => {
|
||||||
|
// Navigate to user detail page
|
||||||
|
if (violation.user?.id) {
|
||||||
|
router.push(`/user/${violation.user.id}` as never);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
|
||||||
|
<FlatList
|
||||||
|
data={violations}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<ViolationCard
|
||||||
|
violation={item}
|
||||||
|
onAcknowledge={() => acknowledgeMutation.mutate(item.id)}
|
||||||
|
onPress={() => handleViolationPress(item)}
|
||||||
|
unitSystem={unitSystem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
contentContainerClassName="p-4 pt-3"
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefetching}
|
||||||
|
onRefresh={refetch}
|
||||||
|
tintColor={colors.cyan.core}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View className="flex-row justify-between items-center mb-3">
|
||||||
|
<View>
|
||||||
|
<Text className="text-lg font-semibold">Alerts</Text>
|
||||||
|
<Text className="text-sm text-muted-foreground">
|
||||||
|
{total} {total === 1 ? 'violation' : 'violations'} total
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{unacknowledgedCount > 0 && (
|
||||||
|
<View className="bg-destructive/20 px-3 py-1.5 rounded-lg">
|
||||||
|
<Text className="text-sm font-medium text-destructive">
|
||||||
|
{unacknowledgedCount} pending
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetchingNextPage ? (
|
||||||
|
<View className="py-4 items-center">
|
||||||
|
<ActivityIndicator size="small" color={colors.cyan.core} />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className="items-center py-16">
|
||||||
|
<View className="w-20 h-20 rounded-full bg-success/10 border border-success/20 items-center justify-center mb-4">
|
||||||
|
<Check size={32} color={colors.success} />
|
||||||
|
</View>
|
||||||
|
<Text className="text-xl font-semibold mb-2">All Clear</Text>
|
||||||
|
<Text className="text-sm text-muted-foreground text-center px-8 leading-5">
|
||||||
|
No rule violations have been detected. Your users are behaving nicely!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
apps/mobile/app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard tab - overview of streaming activity
|
||||||
|
* Query keys include selectedServerId for proper cache isolation per media server
|
||||||
|
*/
|
||||||
|
import { View, ScrollView, RefreshControl } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useMediaServer } from '@/providers/MediaServerProvider';
|
||||||
|
import { useServerStatistics } from '@/hooks/useServerStatistics';
|
||||||
|
import { StreamMap } from '@/components/map/StreamMap';
|
||||||
|
import { NowPlayingCard } from '@/components/sessions';
|
||||||
|
import { ServerResourceCard } from '@/components/server/ServerResourceCard';
|
||||||
|
import { Text } from '@/components/ui/text';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact stat pill for dashboard summary bar
|
||||||
|
*/
|
||||||
|
function StatPill({
|
||||||
|
icon,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
color = colors.text.secondary.dark,
|
||||||
|
}: {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
value: string | number;
|
||||||
|
unit?: string;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.card.dark,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={14} color={color} />
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '600', color: colors.text.primary.dark }}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
{unit && (
|
||||||
|
<Text style={{ fontSize: 11, color: colors.text.muted.dark }}>{unit}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { selectedServerId, selectedServer } = useMediaServer();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: stats,
|
||||||
|
refetch,
|
||||||
|
isRefetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['dashboard', 'stats', selectedServerId],
|
||||||
|
queryFn: () => api.stats.dashboard(selectedServerId ?? undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: activeSessions } = useQuery({
|
||||||
|
queryKey: ['sessions', 'active', selectedServerId],
|
||||||
|
queryFn: () => api.sessions.active(selectedServerId ?? undefined),
|
||||||
|
staleTime: 1000 * 15, // 15 seconds - match web
|
||||||
|
refetchInterval: 1000 * 30, // 30 seconds - fallback if WebSocket events missed
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only show server resources for Plex servers
|
||||||
|
const isPlexServer = selectedServer?.type === 'plex';
|
||||||
|
|
||||||
|
// Poll server statistics only when dashboard is visible and we have a Plex server
|
||||||
|
const {
|
||||||
|
latest: serverResources,
|
||||||
|
isLoadingData: resourcesLoading,
|
||||||
|
error: resourcesError,
|
||||||
|
} = useServerStatistics(selectedServerId ?? undefined, isPlexServer);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerClassName="pb-8"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor={colors.cyan.core} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Today's Stats Bar */}
|
||||||
|
{stats && (
|
||||||
|
<View className="px-4 pt-3 pb-2">
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 11, color: colors.text.muted.dark, fontWeight: '600', marginRight: 2 }}>
|
||||||
|
TODAY
|
||||||
|
</Text>
|
||||||
|
<StatPill icon="play-circle-outline" value={stats.todayPlays} unit="plays" />
|
||||||
|
<StatPill icon="time-outline" value={stats.watchTimeHours} unit="hrs" />
|
||||||
|
<StatPill
|
||||||
|
icon="warning-outline"
|
||||||
|
value={stats.alertsLast24h}
|
||||||
|
unit="alerts"
|
||||||
|
color={stats.alertsLast24h > 0 ? colors.warning : colors.text.muted.dark}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Now Playing - Active Streams */}
|
||||||
|
<View className="px-4 mb-4">
|
||||||
|
<View className="flex-row items-center justify-between mb-3">
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<Ionicons name="tv-outline" size={18} color={colors.cyan.core} />
|
||||||
|
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
Now Playing
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{activeSessions && activeSessions.length > 0 && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(24, 209, 231, 0.15)',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.cyan.core, fontSize: 12, fontWeight: '600' }}>
|
||||||
|
{activeSessions.length} {activeSessions.length === 1 ? 'stream' : 'streams'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{activeSessions && activeSessions.length > 0 ? (
|
||||||
|
<View>
|
||||||
|
{activeSessions.map((session) => (
|
||||||
|
<NowPlayingCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
onPress={() => router.push(`/session/${session.id}` as never)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Card className="py-8">
|
||||||
|
<View className="items-center">
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surface.dark,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 999,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="tv-outline" size={32} color={colors.text.muted.dark} />
|
||||||
|
</View>
|
||||||
|
<Text className="text-base font-semibold">No active streams</Text>
|
||||||
|
<Text className="text-sm text-muted-foreground mt-1">Streams will appear here when users start watching</Text>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Stream Map - only show when there are active streams */}
|
||||||
|
{activeSessions && activeSessions.length > 0 && (
|
||||||
|
<View className="px-4 mb-4">
|
||||||
|
<View className="flex-row items-center gap-2 mb-3">
|
||||||
|
<Ionicons name="location-outline" size={18} color={colors.cyan.core} />
|
||||||
|
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
Stream Locations
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<StreamMap sessions={activeSessions} height={200} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Server Resources - only show if Plex server is active */}
|
||||||
|
{isPlexServer && (
|
||||||
|
<View className="px-4">
|
||||||
|
<View className="flex-row items-center gap-2 mb-3">
|
||||||
|
<Ionicons name="server-outline" size={18} color={colors.cyan.core} />
|
||||||
|
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
Server Resources
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ServerResourceCard
|
||||||
|
latest={serverResources}
|
||||||
|
isLoading={resourcesLoading}
|
||||||
|
error={resourcesError}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
299
apps/mobile/app/(tabs)/settings.tsx
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* Settings tab - notifications, app info
|
||||||
|
* Server selection is handled via the global header selector
|
||||||
|
*/
|
||||||
|
import { View, ScrollView, Pressable, Switch, Alert, ActivityIndicator } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ChevronRight, LogOut } from 'lucide-react-native';
|
||||||
|
import { useAuthStore } from '@/lib/authStore';
|
||||||
|
import { Text } from '@/components/ui/text';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
|
||||||
|
function SettingsRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onPress,
|
||||||
|
showChevron,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
destructive,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
showChevron?: boolean;
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
rightIcon?: React.ReactNode;
|
||||||
|
destructive?: boolean;
|
||||||
|
}) {
|
||||||
|
const content = (
|
||||||
|
<View className="flex-row justify-between items-center px-4 py-3 min-h-[48px]">
|
||||||
|
<View className="flex-row items-center flex-1">
|
||||||
|
{leftIcon && <View className="mr-3">{leftIcon}</View>}
|
||||||
|
<Text className={cn('text-base flex-1', destructive && 'text-destructive')}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row items-center">
|
||||||
|
{value && <Text className="text-base text-muted-foreground text-right ml-4">{value}</Text>}
|
||||||
|
{rightIcon}
|
||||||
|
{showChevron && (
|
||||||
|
<ChevronRight size={20} color={colors.text.muted.dark} className="ml-2" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onPress) {
|
||||||
|
return (
|
||||||
|
<Pressable onPress={onPress} className="active:opacity-70 active:bg-background">
|
||||||
|
{content}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsToggle({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
disabled,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
value: boolean;
|
||||||
|
onValueChange: (value: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row justify-between items-center px-4 py-3 min-h-[48px]">
|
||||||
|
<View className="flex-1 mr-4">
|
||||||
|
<Text className={cn('text-base', disabled && 'opacity-50')}>{label}</Text>
|
||||||
|
{description && (
|
||||||
|
<Text className={cn('text-xs text-muted-foreground mt-0.5', disabled && 'opacity-50')}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.cyan.core} />
|
||||||
|
) : (
|
||||||
|
<Switch
|
||||||
|
value={value}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
disabled={disabled}
|
||||||
|
trackColor={{ false: colors.switch.trackOff, true: colors.switch.trackOn }}
|
||||||
|
thumbColor={value ? colors.switch.thumbOn : colors.switch.thumbOff}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsSection({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="mb-6 px-4">
|
||||||
|
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Card className="p-0 overflow-hidden">{children}</Card>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Divider() {
|
||||||
|
return <View className="h-px bg-border ml-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const {
|
||||||
|
activeServerId,
|
||||||
|
activeServer,
|
||||||
|
isLoading: isAuthLoading,
|
||||||
|
logout,
|
||||||
|
} = useAuthStore();
|
||||||
|
|
||||||
|
const appVersion = Constants.expoConfig?.version || '1.0.0';
|
||||||
|
|
||||||
|
// Fetch notification preferences
|
||||||
|
const {
|
||||||
|
data: preferences,
|
||||||
|
isLoading: isLoadingPrefs,
|
||||||
|
error: prefsError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['notifications', 'preferences'],
|
||||||
|
queryFn: api.notifications.getPreferences,
|
||||||
|
staleTime: 1000 * 60, // 1 minute
|
||||||
|
enabled: !!activeServerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update mutation for quick toggle
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: api.notifications.updatePreferences,
|
||||||
|
onMutate: async (newData) => {
|
||||||
|
await queryClient.cancelQueries({
|
||||||
|
queryKey: ['notifications', 'preferences'],
|
||||||
|
});
|
||||||
|
const previousData = queryClient.getQueryData([
|
||||||
|
'notifications',
|
||||||
|
'preferences',
|
||||||
|
]);
|
||||||
|
queryClient.setQueryData(
|
||||||
|
['notifications', 'preferences'],
|
||||||
|
(old: typeof preferences) => (old ? { ...old, ...newData } : old)
|
||||||
|
);
|
||||||
|
return { previousData };
|
||||||
|
},
|
||||||
|
onError: (_err, _newData, context) => {
|
||||||
|
if (context?.previousData) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
['notifications', 'preferences'],
|
||||||
|
context.previousData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ['notifications', 'preferences'],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDisconnect = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Disconnect from Server',
|
||||||
|
'Are you sure you want to disconnect? You will need to scan a QR code to reconnect.',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Disconnect',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
void (async () => {
|
||||||
|
await queryClient.cancelQueries();
|
||||||
|
await logout();
|
||||||
|
queryClient.clear();
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTogglePush = (value: boolean) => {
|
||||||
|
updateMutation.mutate({ pushEnabled: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToNotificationSettings = () => {
|
||||||
|
router.push('/settings/notifications');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count enabled notification events for summary
|
||||||
|
const enabledEventCount = preferences
|
||||||
|
? [
|
||||||
|
preferences.onViolationDetected,
|
||||||
|
preferences.onStreamStarted,
|
||||||
|
preferences.onStreamStopped,
|
||||||
|
preferences.onConcurrentStreams,
|
||||||
|
preferences.onNewDevice,
|
||||||
|
preferences.onTrustScoreChanged,
|
||||||
|
preferences.onServerDown,
|
||||||
|
preferences.onServerUp,
|
||||||
|
].filter(Boolean).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView
|
||||||
|
style={{ flex: 1, backgroundColor: colors.background.dark }}
|
||||||
|
edges={['left', 'right', 'bottom']}
|
||||||
|
>
|
||||||
|
<ScrollView className="flex-1" contentContainerClassName="py-4">
|
||||||
|
{/* Connected Server Info */}
|
||||||
|
{activeServer && (
|
||||||
|
<SettingsSection title="Connected Server">
|
||||||
|
<SettingsRow label="Name" value={activeServer.name} />
|
||||||
|
<Divider />
|
||||||
|
<SettingsRow label="URL" value={activeServer.url} />
|
||||||
|
<Divider />
|
||||||
|
<SettingsRow
|
||||||
|
label="Disconnect"
|
||||||
|
onPress={handleDisconnect}
|
||||||
|
leftIcon={<LogOut size={20} color={colors.error} />}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notification Settings */}
|
||||||
|
{activeServerId && (
|
||||||
|
<SettingsSection title="Notifications">
|
||||||
|
{prefsError ? (
|
||||||
|
<View className="px-4 py-3">
|
||||||
|
<Text className="text-destructive text-sm">
|
||||||
|
Failed to load notification settings
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SettingsToggle
|
||||||
|
label="Push Notifications"
|
||||||
|
description="Receive alerts on this device"
|
||||||
|
value={preferences?.pushEnabled ?? false}
|
||||||
|
onValueChange={handleTogglePush}
|
||||||
|
isLoading={isLoadingPrefs}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<SettingsRow
|
||||||
|
label="Notification Settings"
|
||||||
|
value={
|
||||||
|
preferences?.pushEnabled ? `${enabledEventCount} events enabled` : 'Disabled'
|
||||||
|
}
|
||||||
|
onPress={navigateToNotificationSettings}
|
||||||
|
showChevron
|
||||||
|
/>
|
||||||
|
<Text className="text-xs text-muted-foreground px-4 py-2 leading-4">
|
||||||
|
Configure which events trigger notifications, quiet hours, and filters.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* App Info */}
|
||||||
|
<SettingsSection title="About">
|
||||||
|
<SettingsRow label="App Version" value={appVersion} />
|
||||||
|
<Divider />
|
||||||
|
<SettingsRow
|
||||||
|
label="Build"
|
||||||
|
value={(Constants.expoConfig?.extra?.buildNumber as string | undefined) ?? 'dev'}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{isAuthLoading && (
|
||||||
|
<View className="px-4 py-8 items-center">
|
||||||
|
<ActivityIndicator color={colors.cyan.core} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
apps/mobile/app/(tabs)/users.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Users tab - user list with infinite scroll
|
||||||
|
* Query keys include selectedServerId for proper cache isolation per media server
|
||||||
|
*/
|
||||||
|
import { View, FlatList, RefreshControl, Pressable, ActivityIndicator } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useMediaServer } from '@/providers/MediaServerProvider';
|
||||||
|
import { Text } from '@/components/ui/text';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
import type { ServerUserWithIdentity } from '@tracearr/shared';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
function TrustScoreBadge({ score }: { score: number }) {
|
||||||
|
const variant = score < 50 ? 'destructive' : score < 75 ? 'warning' : 'success';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1 rounded-sm min-w-[40px] items-center',
|
||||||
|
variant === 'destructive' && 'bg-destructive/20',
|
||||||
|
variant === 'warning' && 'bg-warning/20',
|
||||||
|
variant === 'success' && 'bg-success/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-semibold',
|
||||||
|
variant === 'destructive' && 'text-destructive',
|
||||||
|
variant === 'warning' && 'text-warning',
|
||||||
|
variant === 'success' && 'text-success'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{score}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserCard({ user, onPress }: { user: ServerUserWithIdentity; onPress: () => void }) {
|
||||||
|
return (
|
||||||
|
<Pressable onPress={onPress}>
|
||||||
|
<Card className="flex-row items-center justify-between mb-2 p-3">
|
||||||
|
<View className="flex-row items-center gap-3 flex-1">
|
||||||
|
<UserAvatar thumbUrl={user.thumbUrl} username={user.username} size={48} />
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-base font-semibold">{user.username}</Text>
|
||||||
|
<Text className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
{user.role === 'owner' ? 'Owner' : 'User'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TrustScoreBadge score={user.trustScore} />
|
||||||
|
</Card>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { selectedServerId } = useMediaServer();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
refetch,
|
||||||
|
isRefetching,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['users', selectedServerId],
|
||||||
|
queryFn: ({ pageParam = 1 }) =>
|
||||||
|
api.users.list({ page: pageParam, pageSize: PAGE_SIZE, serverId: selectedServerId ?? undefined }),
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage: { page: number; totalPages: number }) => {
|
||||||
|
if (lastPage.page < lastPage.totalPages) {
|
||||||
|
return lastPage.page + 1;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flatten all pages into single array
|
||||||
|
const users = data?.pages.flatMap((page) => page.data) || [];
|
||||||
|
const total = data?.pages[0]?.total || 0;
|
||||||
|
|
||||||
|
const handleEndReached = () => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
void fetchNextPage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
|
||||||
|
<FlatList
|
||||||
|
data={users}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<UserCard
|
||||||
|
user={item}
|
||||||
|
onPress={() => router.push(`/user/${item.id}` as never)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
contentContainerClassName="p-4 pt-3"
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefetching}
|
||||||
|
onRefresh={refetch}
|
||||||
|
tintColor={colors.cyan.core}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View className="flex-row justify-between items-center mb-3">
|
||||||
|
<Text className="text-lg font-semibold">Users</Text>
|
||||||
|
<Text className="text-sm text-muted-foreground">
|
||||||
|
{total} {total === 1 ? 'user' : 'users'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetchingNextPage ? (
|
||||||
|
<View className="py-4 items-center">
|
||||||
|
<ActivityIndicator size="small" color={colors.cyan.core} />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className="items-center py-12">
|
||||||
|
<View className="w-16 h-16 rounded-full bg-card border border-border items-center justify-center mb-4">
|
||||||
|
<Text className="text-2xl text-muted-foreground">0</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-lg font-semibold mb-1">No Users</Text>
|
||||||
|
<Text className="text-sm text-muted-foreground text-center px-4">
|
||||||
|
Users will appear here after syncing with your media server
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
apps/mobile/app/+not-found.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* 404 Not Found screen
|
||||||
|
*/
|
||||||
|
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||||
|
import { Stack, useRouter } from 'expo-router';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { colors, spacing, borderRadius, typography } from '@/lib/theme';
|
||||||
|
|
||||||
|
export default function NotFoundScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Page Not Found' }} />
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.errorCode}>404</Text>
|
||||||
|
<Text style={styles.title}>Page Not Found</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
The page you're looking for doesn't exist or has been moved.
|
||||||
|
</Text>
|
||||||
|
<Pressable style={styles.button} onPress={() => router.replace('/')}>
|
||||||
|
<Text style={styles.buttonText}>Go Home</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background.dark,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
errorCode: {
|
||||||
|
fontSize: 72,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.cyan.core,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: typography.fontSize['2xl'],
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.text.primary.dark,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.secondary.dark,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: colors.cyan.core,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.blue.core,
|
||||||
|
},
|
||||||
|
});
|
||||||
128
apps/mobile/app/_layout.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Root layout - handles auth state and navigation
|
||||||
|
*/
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
|
global.Buffer = Buffer;
|
||||||
|
|
||||||
|
import '../global.css';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Stack, useRouter, useSegments } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { View, ActivityIndicator, StyleSheet } from 'react-native';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import { QueryProvider } from '@/providers/QueryProvider';
|
||||||
|
import { SocketProvider } from '@/providers/SocketProvider';
|
||||||
|
import { MediaServerProvider } from '@/providers/MediaServerProvider';
|
||||||
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
|
import { useAuthStore } from '@/lib/authStore';
|
||||||
|
import { usePushNotifications } from '@/hooks/usePushNotifications';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
|
||||||
|
function RootLayoutNav() {
|
||||||
|
const { isAuthenticated, isLoading, initialize } = useAuthStore();
|
||||||
|
const segments = useSegments();
|
||||||
|
const router = useRouter();
|
||||||
|
const [hasInitialized, setHasInitialized] = useState(false);
|
||||||
|
|
||||||
|
// Initialize push notifications (only when authenticated)
|
||||||
|
usePushNotifications();
|
||||||
|
|
||||||
|
// Initialize auth state on mount
|
||||||
|
useEffect(() => {
|
||||||
|
void initialize().finally(() => setHasInitialized(true));
|
||||||
|
}, [initialize]);
|
||||||
|
|
||||||
|
// Handle navigation based on auth state
|
||||||
|
// Note: We allow authenticated users to access (auth)/pair for adding servers
|
||||||
|
// Wait for initialization to complete before redirecting (prevents hot reload issues)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || !hasInitialized) return;
|
||||||
|
|
||||||
|
const inAuthGroup = segments[0] === '(auth)';
|
||||||
|
|
||||||
|
if (!isAuthenticated && !inAuthGroup) {
|
||||||
|
// Not authenticated and not on auth screen - redirect to pair
|
||||||
|
router.replace('/(auth)/pair');
|
||||||
|
}
|
||||||
|
// Don't redirect away from pair if authenticated - user might be adding a server
|
||||||
|
}, [isAuthenticated, isLoading, hasInitialized, segments, router]);
|
||||||
|
|
||||||
|
// Show loading screen while initializing
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.cyan.core} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StatusBar style="light" backgroundColor={colors.background.dark} translucent={false} />
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: colors.background.dark },
|
||||||
|
animation: 'fade',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="user"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: 'card',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="session"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: 'card',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: 'modal',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name="+not-found" />
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<GestureHandlerRootView style={styles.container}>
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<QueryProvider>
|
||||||
|
<SocketProvider>
|
||||||
|
<MediaServerProvider>
|
||||||
|
<RootLayoutNav />
|
||||||
|
</MediaServerProvider>
|
||||||
|
</SocketProvider>
|
||||||
|
</QueryProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background.dark,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.background.dark,
|
||||||
|
},
|
||||||
|
});
|
||||||
524
apps/mobile/app/session/[id].tsx
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
/**
|
||||||
|
* Session detail screen
|
||||||
|
* Shows comprehensive information about a specific session/stream
|
||||||
|
* Query keys include selectedServerId for proper cache isolation per media server
|
||||||
|
*/
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { View, Text, ScrollView, Pressable, ActivityIndicator, Image, Alert } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Square,
|
||||||
|
User,
|
||||||
|
Server,
|
||||||
|
MapPin,
|
||||||
|
Smartphone,
|
||||||
|
Clock,
|
||||||
|
Gauge,
|
||||||
|
Tv,
|
||||||
|
Film,
|
||||||
|
Music,
|
||||||
|
Zap,
|
||||||
|
Globe,
|
||||||
|
Wifi,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react-native';
|
||||||
|
import { api, getServerUrl } from '@/lib/api';
|
||||||
|
import { useMediaServer } from '@/providers/MediaServerProvider';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type { SessionWithDetails, SessionState, MediaType } from '@tracearr/shared';
|
||||||
|
|
||||||
|
// Safe date parsing helper - handles string dates from API
|
||||||
|
function safeParseDate(date: Date | string | null | undefined): Date | null {
|
||||||
|
if (!date) return null;
|
||||||
|
const parsed = new Date(date);
|
||||||
|
return isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe format date helper
|
||||||
|
function safeFormatDate(date: Date | string | null | undefined, formatStr: string): string {
|
||||||
|
const parsed = safeParseDate(date);
|
||||||
|
if (!parsed) return 'Unknown';
|
||||||
|
return format(parsed, formatStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get state icon, color, and badge variant
|
||||||
|
function getStateInfo(state: SessionState, watched?: boolean): {
|
||||||
|
icon: typeof Play;
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
variant: 'success' | 'warning' | 'secondary';
|
||||||
|
} {
|
||||||
|
// Show "Watched" for completed sessions where user watched 80%+
|
||||||
|
if (watched && state === 'stopped') {
|
||||||
|
return { icon: Play, color: colors.success, label: 'Watched', variant: 'success' };
|
||||||
|
}
|
||||||
|
switch (state) {
|
||||||
|
case 'playing':
|
||||||
|
return { icon: Play, color: colors.success, label: 'Playing', variant: 'success' };
|
||||||
|
case 'paused':
|
||||||
|
return { icon: Pause, color: colors.warning, label: 'Paused', variant: 'warning' };
|
||||||
|
case 'stopped':
|
||||||
|
return { icon: Square, color: colors.text.secondary.dark, label: 'Stopped', variant: 'secondary' };
|
||||||
|
default:
|
||||||
|
return { icon: Square, color: colors.text.secondary.dark, label: 'Unknown', variant: 'secondary' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get media type icon
|
||||||
|
function getMediaIcon(mediaType: MediaType): typeof Film {
|
||||||
|
switch (mediaType) {
|
||||||
|
case 'movie':
|
||||||
|
return Film;
|
||||||
|
case 'episode':
|
||||||
|
return Tv;
|
||||||
|
case 'track':
|
||||||
|
return Music;
|
||||||
|
default:
|
||||||
|
return Film;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format duration
|
||||||
|
function formatDuration(ms: number | null): string {
|
||||||
|
if (ms === null) return '-';
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}m ${secs}s`;
|
||||||
|
}
|
||||||
|
return `${secs}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format bitrate
|
||||||
|
function formatBitrate(bitrate: number | null): string {
|
||||||
|
if (bitrate === null) return '-';
|
||||||
|
if (bitrate >= 1000) {
|
||||||
|
return `${(bitrate / 1000).toFixed(1)} Mbps`;
|
||||||
|
}
|
||||||
|
return `${bitrate} Kbps`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info card component
|
||||||
|
function InfoCard({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="bg-card rounded-xl p-4 mb-4">
|
||||||
|
<Text className="text-muted-foreground text-sm font-medium mb-3">{title}</Text>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info row component
|
||||||
|
function InfoRow({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueColor,
|
||||||
|
}: {
|
||||||
|
icon: typeof Play;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
valueColor?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center py-2 border-b border-border last:border-b-0">
|
||||||
|
<Icon size={18} color={colors.text.secondary.dark} />
|
||||||
|
<Text className="text-muted-foreground text-sm ml-3 flex-1">{label}</Text>
|
||||||
|
<Text
|
||||||
|
className="text-sm font-medium"
|
||||||
|
style={{ color: valueColor || colors.text.primary.dark }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress bar component
|
||||||
|
function ProgressBar({
|
||||||
|
progress,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
progress: number | null;
|
||||||
|
total: number | null;
|
||||||
|
}) {
|
||||||
|
if (progress === null || total === null || total === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentage = Math.min((progress / total) * 100, 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 12 }}>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<Text style={{ color: colors.text.secondary.dark, fontSize: 12 }}>{formatDuration(progress)}</Text>
|
||||||
|
<Text style={{ color: colors.text.secondary.dark, fontSize: 12 }}>{formatDuration(total)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ backgroundColor: '#27272a', height: 8, borderRadius: 4, overflow: 'hidden' }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.cyan.core,
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 4,
|
||||||
|
width: `${percentage}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: '#71717a', fontSize: 12, textAlign: 'center', marginTop: 4 }}>
|
||||||
|
{percentage.toFixed(1)}% watched
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SessionDetailScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { selectedServerId } = useMediaServer();
|
||||||
|
const [serverUrl, setServerUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load server URL for image paths
|
||||||
|
useEffect(() => {
|
||||||
|
void getServerUrl().then(setServerUrl);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Terminate session mutation
|
||||||
|
const terminateMutation = useMutation({
|
||||||
|
mutationFn: ({ sessionId, reason }: { sessionId: string; reason?: string }) =>
|
||||||
|
api.sessions.terminate(sessionId, reason),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
|
||||||
|
Alert.alert('Stream Terminated', 'The playback session has been stopped.');
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
Alert.alert('Failed to Terminate', error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle terminate button press
|
||||||
|
const handleTerminate = () => {
|
||||||
|
Alert.prompt(
|
||||||
|
'Terminate Stream',
|
||||||
|
'Enter an optional message to show the user (leave empty to skip):',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Terminate',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: (reason: string | undefined) => {
|
||||||
|
terminateMutation.mutate({ sessionId: id, reason: reason?.trim() || undefined });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'plain-text',
|
||||||
|
'',
|
||||||
|
'default'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: session,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery<SessionWithDetails>({
|
||||||
|
queryKey: ['session', id, selectedServerId],
|
||||||
|
queryFn: async () => {
|
||||||
|
console.log('[SessionDetail] Fetching session:', id);
|
||||||
|
try {
|
||||||
|
const result = await api.sessions.get(id);
|
||||||
|
console.log('[SessionDetail] Received session data:', JSON.stringify(result, null, 2));
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SessionDetail] API error:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[SessionDetail] State:', { id, isLoading, hasError: !!error, hasSession: !!session });
|
||||||
|
if (error) {
|
||||||
|
console.error('[SessionDetail] Query error:', error);
|
||||||
|
}
|
||||||
|
if (session) {
|
||||||
|
console.log('[SessionDetail] Session fields:', {
|
||||||
|
id: session.id,
|
||||||
|
username: session.username,
|
||||||
|
mediaTitle: session.mediaTitle,
|
||||||
|
state: session.state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [id, isLoading, error, session]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView
|
||||||
|
style={{ flex: 1, backgroundColor: colors.background.dark, justifyContent: 'center', alignItems: 'center' }}
|
||||||
|
edges={['left', 'right', 'bottom']}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size="large" color={colors.cyan.core} />
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !session) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView
|
||||||
|
style={{ flex: 1, backgroundColor: colors.background.dark, justifyContent: 'center', alignItems: 'center', padding: 16 }}
|
||||||
|
edges={['left', 'right', 'bottom']}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#f87171', textAlign: 'center' }}>
|
||||||
|
{error instanceof Error ? error.message : 'Failed to load session'}
|
||||||
|
</Text>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateInfo = getStateInfo(session.state, session.watched);
|
||||||
|
const MediaIcon = getMediaIcon(session.mediaType);
|
||||||
|
|
||||||
|
// Format media title with episode info
|
||||||
|
const getMediaTitle = (): string => {
|
||||||
|
if (session.mediaType === 'episode' && session.grandparentTitle) {
|
||||||
|
const episodeInfo = session.seasonNumber && session.episodeNumber
|
||||||
|
? `S${session.seasonNumber}E${session.episodeNumber}`
|
||||||
|
: '';
|
||||||
|
return `${session.grandparentTitle}${episodeInfo ? ` • ${episodeInfo}` : ''}`;
|
||||||
|
}
|
||||||
|
return session.mediaTitle;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSubtitle = (): string => {
|
||||||
|
if (session.mediaType === 'episode') {
|
||||||
|
return session.mediaTitle; // Episode title
|
||||||
|
}
|
||||||
|
if (session.year) {
|
||||||
|
return String(session.year);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get location string
|
||||||
|
const getLocation = (): string => {
|
||||||
|
const parts = [session.geoCity, session.geoRegion, session.geoCountry].filter(Boolean);
|
||||||
|
return parts.join(', ') || 'Unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16 }}>
|
||||||
|
{/* Media Header */}
|
||||||
|
<View className="bg-card rounded-xl p-4 mb-4">
|
||||||
|
{/* Terminate button - top right */}
|
||||||
|
<View className="absolute top-2 right-2 z-10">
|
||||||
|
<Pressable
|
||||||
|
onPress={handleTerminate}
|
||||||
|
disabled={terminateMutation.isPending}
|
||||||
|
className="w-8 h-8 rounded-full bg-destructive/10 items-center justify-center active:opacity-70"
|
||||||
|
style={{ opacity: terminateMutation.isPending ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
<X size={18} color="#ef4444" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row items-start">
|
||||||
|
{/* Poster/Thumbnail */}
|
||||||
|
<View className="w-20 h-28 bg-surface rounded-lg mr-4 overflow-hidden">
|
||||||
|
{session.thumbPath && serverUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: `${serverUrl}/api/v1/images/proxy?server=${session.serverId}&url=${encodeURIComponent(session.thumbPath)}&width=160&height=224` }}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className="w-full h-full justify-center items-center">
|
||||||
|
<MediaIcon size={32} color={colors.text.secondary.dark} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Media Info */}
|
||||||
|
<View className="flex-1">
|
||||||
|
<View className="flex-row items-center mb-2">
|
||||||
|
<Badge variant={stateInfo.variant}>
|
||||||
|
{stateInfo.label}
|
||||||
|
</Badge>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-white text-lg font-semibold" numberOfLines={2}>
|
||||||
|
{getMediaTitle()}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{getSubtitle() ? (
|
||||||
|
<Text className="text-muted-foreground text-sm mt-1" numberOfLines={1}>
|
||||||
|
{getSubtitle()}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View className="flex-row items-center mt-2">
|
||||||
|
<MediaIcon size={14} color={colors.text.secondary.dark} />
|
||||||
|
<Text className="text-muted-foreground text-xs ml-1 capitalize">
|
||||||
|
{session.mediaType}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<ProgressBar progress={session.progressMs} total={session.totalDurationMs} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* User Card - Tappable */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push(`/user/${session.serverUserId}` as never)}
|
||||||
|
className="bg-card rounded-xl p-4 mb-4 active:opacity-70"
|
||||||
|
>
|
||||||
|
<Text className="text-muted-foreground text-sm font-medium mb-3">User</Text>
|
||||||
|
<View className="flex-row items-center">
|
||||||
|
<View className="w-12 h-12 rounded-full bg-surface overflow-hidden">
|
||||||
|
{session.userThumb ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: session.userThumb }}
|
||||||
|
className="w-full h-full"
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className="w-full h-full justify-center items-center">
|
||||||
|
<User size={24} color={colors.text.secondary.dark} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="flex-1 ml-3">
|
||||||
|
<Text className="text-foreground text-base font-semibold">
|
||||||
|
{session.username}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-sm">Tap to view profile</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-primary text-sm">→</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Server Info */}
|
||||||
|
<InfoCard title="Server">
|
||||||
|
<View className="flex-row items-center">
|
||||||
|
<Server size={20} color={colors.text.secondary.dark} />
|
||||||
|
<View className="flex-1 ml-3">
|
||||||
|
<Text className="text-foreground text-base font-medium">
|
||||||
|
{session.serverName}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-sm capitalize">
|
||||||
|
{session.serverType}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</InfoCard>
|
||||||
|
|
||||||
|
{/* Timing Info */}
|
||||||
|
<InfoCard title="Timing">
|
||||||
|
<InfoRow
|
||||||
|
icon={Clock}
|
||||||
|
label="Started"
|
||||||
|
value={safeFormatDate(session.startedAt, 'MMM d, yyyy h:mm a')}
|
||||||
|
/>
|
||||||
|
{session.stoppedAt && (
|
||||||
|
<InfoRow
|
||||||
|
icon={Square}
|
||||||
|
label="Stopped"
|
||||||
|
value={safeFormatDate(session.stoppedAt, 'MMM d, yyyy h:mm a')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<InfoRow
|
||||||
|
icon={Play}
|
||||||
|
label="Watch Time"
|
||||||
|
value={formatDuration(session.durationMs)}
|
||||||
|
/>
|
||||||
|
{(session.pausedDurationMs ?? 0) > 0 && (
|
||||||
|
<InfoRow
|
||||||
|
icon={Pause}
|
||||||
|
label="Paused Time"
|
||||||
|
value={formatDuration(session.pausedDurationMs)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</InfoCard>
|
||||||
|
|
||||||
|
{/* Location Info */}
|
||||||
|
<InfoCard title="Location">
|
||||||
|
<InfoRow icon={Globe} label="IP Address" value={session.ipAddress || 'Unknown'} />
|
||||||
|
<InfoRow icon={MapPin} label="Location" value={getLocation()} />
|
||||||
|
{session.geoLat && session.geoLon && (
|
||||||
|
<InfoRow
|
||||||
|
icon={MapPin}
|
||||||
|
label="Coordinates"
|
||||||
|
value={`${session.geoLat.toFixed(4)}, ${session.geoLon.toFixed(4)}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</InfoCard>
|
||||||
|
|
||||||
|
{/* Device Info */}
|
||||||
|
<InfoCard title="Device">
|
||||||
|
<InfoRow
|
||||||
|
icon={Smartphone}
|
||||||
|
label="Player"
|
||||||
|
value={session.playerName || 'Unknown'}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
icon={Tv}
|
||||||
|
label="Device"
|
||||||
|
value={session.device || 'Unknown'}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
icon={Wifi}
|
||||||
|
label="Platform"
|
||||||
|
value={session.platform || 'Unknown'}
|
||||||
|
/>
|
||||||
|
{session.product && (
|
||||||
|
<InfoRow icon={Smartphone} label="Product" value={session.product} />
|
||||||
|
)}
|
||||||
|
</InfoCard>
|
||||||
|
|
||||||
|
{/* Quality Info */}
|
||||||
|
<InfoCard title="Quality">
|
||||||
|
<InfoRow
|
||||||
|
icon={Gauge}
|
||||||
|
label="Quality"
|
||||||
|
value={session.quality || 'Unknown'}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
icon={Zap}
|
||||||
|
label="Transcode"
|
||||||
|
value={session.isTranscode ? 'Yes' : 'Direct Play'}
|
||||||
|
valueColor={session.isTranscode ? colors.warning : colors.success}
|
||||||
|
/>
|
||||||
|
{session.bitrate && (
|
||||||
|
<InfoRow icon={Gauge} label="Bitrate" value={formatBitrate(session.bitrate)} />
|
||||||
|
)}
|
||||||
|
</InfoCard>
|
||||||
|
|
||||||
|
{/* Bottom padding */}
|
||||||
|
<View className="h-8" />
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/mobile/app/session/_layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Session detail stack navigator layout
|
||||||
|
* Provides navigation for session detail screens
|
||||||
|
*/
|
||||||
|
import { Stack, useRouter } from 'expo-router';
|
||||||
|
import { Pressable } from 'react-native';
|
||||||
|
import { ChevronLeft } from 'lucide-react-native';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
|
||||||
|
export default function SessionLayout() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: true,
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: colors.background.dark,
|
||||||
|
},
|
||||||
|
headerTintColor: colors.text.primary.dark,
|
||||||
|
headerTitleStyle: {
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
headerBackTitle: 'Back',
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable onPress={() => router.back()} hitSlop={8}>
|
||||||
|
<ChevronLeft size={28} color={colors.text.primary.dark} />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: colors.background.dark,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="[id]"
|
||||||
|
options={{
|
||||||
|
title: 'Session',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/mobile/app/settings/_layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Settings stack navigator layout
|
||||||
|
* Provides navigation between settings sub-screens
|
||||||
|
*/
|
||||||
|
import { Stack, useRouter } from 'expo-router';
|
||||||
|
import { Pressable } from 'react-native';
|
||||||
|
import { ChevronLeft } from 'lucide-react-native';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
|
||||||
|
export default function SettingsLayout() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: true,
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: colors.background.dark,
|
||||||
|
},
|
||||||
|
headerTintColor: colors.text.primary.dark,
|
||||||
|
headerTitleStyle: {
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
headerBackTitle: 'Back',
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable onPress={() => router.back()} hitSlop={8}>
|
||||||
|
<ChevronLeft size={28} color={colors.text.primary.dark} />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: colors.background.dark,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="notifications"
|
||||||
|
options={{
|
||||||
|
title: 'Notification Settings',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
538
apps/mobile/app/settings/notifications.tsx
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
/**
|
||||||
|
* Notification Settings Screen
|
||||||
|
* Per-device push notification configuration
|
||||||
|
*/
|
||||||
|
import { View, ScrollView, Switch, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
ShieldAlert,
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
Monitor,
|
||||||
|
Smartphone,
|
||||||
|
AlertTriangle,
|
||||||
|
ServerCrash,
|
||||||
|
ServerCog,
|
||||||
|
Moon,
|
||||||
|
Flame,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react-native';
|
||||||
|
import { Text } from '@/components/ui/text';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useAuthStore } from '@/lib/authStore';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
import type { NotificationPreferences } from '@tracearr/shared';
|
||||||
|
|
||||||
|
// Rule types for violation filtering
|
||||||
|
const RULE_TYPES = [
|
||||||
|
{ value: 'impossible_travel', label: 'Impossible Travel' },
|
||||||
|
{ value: 'simultaneous_locations', label: 'Simultaneous Locations' },
|
||||||
|
{ value: 'device_velocity', label: 'Device Velocity' },
|
||||||
|
{ value: 'concurrent_streams', label: 'Concurrent Streams' },
|
||||||
|
{ value: 'geo_restriction', label: 'Geo Restriction' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Severity levels
|
||||||
|
const SEVERITY_LEVELS = [
|
||||||
|
{ value: 1, label: 'All (Low, Warning, High)' },
|
||||||
|
{ value: 2, label: 'Warning & High only' },
|
||||||
|
{ value: 3, label: 'High severity only' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function Divider() {
|
||||||
|
return <View className="h-px bg-border ml-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsSection({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="mb-6">
|
||||||
|
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Card className="p-0 overflow-hidden">{children}</Card>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingRow({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
icon?: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
value: boolean;
|
||||||
|
onValueChange: (value: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row justify-between items-center px-4 py-3 min-h-[52px]">
|
||||||
|
<View className="flex-1 mr-4">
|
||||||
|
<View className="flex-row items-center">
|
||||||
|
{Icon && (
|
||||||
|
<Icon
|
||||||
|
size={18}
|
||||||
|
color={disabled ? colors.text.muted.dark : colors.text.secondary.dark}
|
||||||
|
style={{ marginRight: 10 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text className={cn('text-base', disabled && 'opacity-50')}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
{description && (
|
||||||
|
<Text className={cn('text-xs text-muted-foreground mt-0.5', Icon && 'ml-7', disabled && 'opacity-50')}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={value}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
disabled={disabled}
|
||||||
|
trackColor={{ false: colors.switch.trackOff, true: colors.switch.trackOn }}
|
||||||
|
thumbColor={value ? colors.switch.thumbOn : colors.switch.thumbOff}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
options: ReadonlyArray<{ value: number; label: string }>;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const currentOption = options.find((o) => o.value === value);
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
label,
|
||||||
|
undefined,
|
||||||
|
options.map((option) => ({
|
||||||
|
text: option.label,
|
||||||
|
onPress: () => onChange(option.value),
|
||||||
|
style: option.value === value ? 'cancel' : 'default',
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn('px-4 py-3 min-h-[52px]', 'active:opacity-70')}
|
||||||
|
>
|
||||||
|
<Text className={cn('text-sm text-muted-foreground mb-1', disabled && 'opacity-50')}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text className={cn('text-base', disabled && 'opacity-50')}>
|
||||||
|
{currentOption?.label ?? 'Select...'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiSelectRow({
|
||||||
|
selectedValues,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
selectedValues: string[];
|
||||||
|
options: ReadonlyArray<{ value: string; label: string }>;
|
||||||
|
onChange: (values: string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const toggleValue = (value: string) => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (selectedValues.includes(value)) {
|
||||||
|
onChange(selectedValues.filter((v) => v !== value));
|
||||||
|
} else {
|
||||||
|
onChange([...selectedValues, value]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allSelected = selectedValues.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-4 py-3">
|
||||||
|
<View className="flex-row flex-wrap gap-2.5">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onChange([])}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 rounded-full border',
|
||||||
|
allSelected
|
||||||
|
? 'bg-cyan-core border-cyan-core'
|
||||||
|
: 'border-border bg-card',
|
||||||
|
disabled && 'opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={cn(
|
||||||
|
'text-sm',
|
||||||
|
allSelected ? 'text-background' : 'text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
All Types
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = selectedValues.includes(option.value);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.value}
|
||||||
|
onPress={() => toggleValue(option.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 rounded-full border',
|
||||||
|
isSelected
|
||||||
|
? 'bg-cyan-core border-cyan-core'
|
||||||
|
: 'border-border bg-card',
|
||||||
|
disabled && 'opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={cn(
|
||||||
|
'text-sm',
|
||||||
|
isSelected ? 'text-background' : 'text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RateLimitStatus({
|
||||||
|
remainingMinute,
|
||||||
|
remainingHour,
|
||||||
|
maxPerMinute,
|
||||||
|
maxPerHour,
|
||||||
|
}: {
|
||||||
|
remainingMinute?: number;
|
||||||
|
remainingHour?: number;
|
||||||
|
maxPerMinute: number;
|
||||||
|
maxPerHour: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="px-4 py-3">
|
||||||
|
<Text className="text-sm text-muted-foreground mb-2">Current Rate Limit Status</Text>
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<View className="flex-1 p-3 rounded-lg bg-surface">
|
||||||
|
<Text className="text-xs text-muted-foreground mb-1">Per Minute</Text>
|
||||||
|
<Text className="text-lg font-semibold">
|
||||||
|
{remainingMinute ?? maxPerMinute} / {maxPerMinute}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1 p-3 rounded-lg bg-surface">
|
||||||
|
<Text className="text-xs text-muted-foreground mb-1">Per Hour</Text>
|
||||||
|
<Text className="text-lg font-semibold">
|
||||||
|
{remainingHour ?? maxPerHour} / {maxPerHour}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationSettingsScreen() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { activeServerId } = useAuthStore();
|
||||||
|
|
||||||
|
// Fetch current preferences (per-device, not per-server)
|
||||||
|
const {
|
||||||
|
data: preferences,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['notifications', 'preferences'],
|
||||||
|
queryFn: api.notifications.getPreferences,
|
||||||
|
enabled: !!activeServerId, // Still need auth
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update mutation with optimistic updates
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: api.notifications.updatePreferences,
|
||||||
|
onMutate: async (newData) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['notifications', 'preferences'] });
|
||||||
|
const previousData = queryClient.getQueryData<NotificationPreferences>([
|
||||||
|
'notifications',
|
||||||
|
'preferences',
|
||||||
|
]);
|
||||||
|
queryClient.setQueryData(['notifications', 'preferences'], (old: NotificationPreferences | undefined) =>
|
||||||
|
old ? { ...old, ...newData } : old
|
||||||
|
);
|
||||||
|
return { previousData };
|
||||||
|
},
|
||||||
|
onError: (_err, _newData, context) => {
|
||||||
|
if (context?.previousData) {
|
||||||
|
queryClient.setQueryData(['notifications', 'preferences'], context.previousData);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['notifications', 'preferences'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test notification mutation
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: api.notifications.sendTest,
|
||||||
|
onSuccess: (result) => {
|
||||||
|
Alert.alert(
|
||||||
|
result.success ? 'Test Sent' : 'Test Failed',
|
||||||
|
result.message
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
Alert.alert('Error', error.message || 'Failed to send test notification');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUpdate = (
|
||||||
|
key: keyof Omit<NotificationPreferences, 'id' | 'mobileSessionId' | 'createdAt' | 'updatedAt'>,
|
||||||
|
value: boolean | number | string[]
|
||||||
|
) => {
|
||||||
|
updateMutation.mutate({ [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color={colors.cyan.core} />
|
||||||
|
<Text className="mt-4 text-muted-foreground">Loading preferences...</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !preferences) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
|
||||||
|
<View className="flex-1 items-center justify-center px-8">
|
||||||
|
<Text className="text-xl font-semibold text-center mb-2">
|
||||||
|
Unable to Load Preferences
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-center">
|
||||||
|
{error instanceof Error ? error.message : 'An error occurred'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushEnabled = preferences.pushEnabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
|
||||||
|
<ScrollView className="flex-1" contentContainerClassName="p-4">
|
||||||
|
{/* Master Toggle */}
|
||||||
|
<SettingsSection title="Push Notifications">
|
||||||
|
<SettingRow
|
||||||
|
icon={Bell}
|
||||||
|
label="Enable Push Notifications"
|
||||||
|
description="Receive alerts on this device"
|
||||||
|
value={pushEnabled}
|
||||||
|
onValueChange={(v) => handleUpdate('pushEnabled', v)}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Event Toggles */}
|
||||||
|
<SettingsSection title="Notification Events">
|
||||||
|
<SettingRow
|
||||||
|
icon={ShieldAlert}
|
||||||
|
label="Violation Detected"
|
||||||
|
description="Rule violation triggered"
|
||||||
|
value={preferences.onViolationDetected}
|
||||||
|
onValueChange={(v) => handleUpdate('onViolationDetected', v)}
|
||||||
|
disabled={!pushEnabled}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<SettingRow
|
||||||
|
icon={Play}
|
||||||
|
label="Stream Started"
|
||||||
|
description="New playback began"
|
||||||
|
value={preferences.onStreamStarted}
|
||||||
|
onValueChange={(v) => handleUpdate('onStreamStarted', v)}
|
||||||
|
disabled={!pushEnabled}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<SettingRow
|
||||||
|
icon={Square}
|
||||||
|
label="Stream Stopped"
|
||||||
|
description="Playback ended"
|
||||||
|
value={preferences.onStreamStopped}
|
||||||
|
onValueChange={(v) => handleUpdate('onStreamStopped', v)}
|
||||||
|
disabled={!pushEnabled}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<SettingRow
|
||||||
|
icon={Monitor}
|
||||||
|
label="Concurrent Streams"
|
||||||
|
description="User exceeded stream limit"
|
||||||
|
value={preferences.onConcurrentStreams}
|
||||||
|
onValueChange={(v) => handleUpdate('onConcurrentStreams', v)}
|
||||||
|
disabled={!pushEnabled}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<SettingRow
|
||||||
|
icon={Smartphone}
|
||||||
|
label="New Device"
|
||||||
|
description="New device detected for user"
|
||||||
|
value={preferences.onNewDevice}
|
||||||
|
onValueChange={(v) => handleUpdate('onNewDevice', v)}
|
||||||
|
disabled={!pushEnabled}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<SettingRow
|
||||||
|
icon={AlertTriangle}
|
||||||
|
label="Trust Score Changed"
|
||||||
|
description="User trust score degraded"
|
||||||
|
value={preferences.onTrustScoreChanged}
|
||||||
|
onValueChange={(v) => handleUpdate('onTrustScoreChanged', v)}
|
||||||
|
disabled={!pushEnabled}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<SettingRow
|
||||||
|
icon={ServerCrash}
|
||||||
|
label="Server Down"
|
||||||
|
description="Media server unreachable"
|
||||||
|
value={preferences.onServerDown}
|
||||||
|
onValueChange={(v) => handleUpdate('onServerDown', v)}
|
||||||
|
disabled={!pushEnabled}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<SettingRow
|
||||||
|
icon={ServerCog}
|
||||||
|
label="Server Up"
|
||||||
|
description="Media server back online"
|
||||||
|
value={preferences.onServerUp}
|
||||||
|
onValueChange={(v) => handleUpdate('onServerUp', v)}
|
||||||
|
disabled={!pushEnabled}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Violation Filters - Only show if violation notifications are enabled */}
|
||||||
|
{pushEnabled && preferences.onViolationDetected && (
|
||||||
|
<SettingsSection title="Violation Filters">
|
||||||
|
<MultiSelectRow
|
||||||
|
selectedValues={preferences.violationRuleTypes}
|
||||||
|
options={RULE_TYPES}
|
||||||
|
onChange={(values) => handleUpdate('violationRuleTypes', values)}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<SelectRow
|
||||||
|
label="Minimum Severity"
|
||||||
|
value={preferences.violationMinSeverity}
|
||||||
|
options={SEVERITY_LEVELS}
|
||||||
|
onChange={(value) => handleUpdate('violationMinSeverity', value)}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quiet Hours */}
|
||||||
|
<SettingsSection title="Quiet Hours">
|
||||||
|
<SettingRow
|
||||||
|
icon={Moon}
|
||||||
|
label="Enable Quiet Hours"
|
||||||
|
description="Pause non-critical notifications during set hours"
|
||||||
|
value={preferences.quietHoursEnabled}
|
||||||
|
onValueChange={(v) => handleUpdate('quietHoursEnabled', v)}
|
||||||
|
disabled={!pushEnabled}
|
||||||
|
/>
|
||||||
|
{pushEnabled && preferences.quietHoursEnabled && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<View className="px-4 py-3">
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<View>
|
||||||
|
<Text className="text-sm text-muted-foreground">Start Time</Text>
|
||||||
|
<Text className="text-base">{preferences.quietHoursStart ?? '23:00'}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-muted-foreground mx-4">to</Text>
|
||||||
|
<View>
|
||||||
|
<Text className="text-sm text-muted-foreground">End Time</Text>
|
||||||
|
<Text className="text-base">{preferences.quietHoursEnd ?? '08:00'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text className="text-xs text-muted-foreground mt-2">
|
||||||
|
Timezone: {preferences.quietHoursTimezone || 'UTC'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Divider />
|
||||||
|
<SettingRow
|
||||||
|
icon={Flame}
|
||||||
|
label="Override for Critical"
|
||||||
|
description="High-severity violations still notify during quiet hours"
|
||||||
|
value={preferences.quietHoursOverrideCritical}
|
||||||
|
onValueChange={(v) => handleUpdate('quietHoursOverrideCritical', v)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Rate Limiting */}
|
||||||
|
<SettingsSection title="Rate Limiting">
|
||||||
|
<RateLimitStatus
|
||||||
|
remainingMinute={preferences.rateLimitStatus?.remainingMinute}
|
||||||
|
remainingHour={preferences.rateLimitStatus?.remainingHour}
|
||||||
|
maxPerMinute={preferences.maxPerMinute}
|
||||||
|
maxPerHour={preferences.maxPerHour}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<View className="px-4 py-2">
|
||||||
|
<Text className="text-xs text-muted-foreground leading-4">
|
||||||
|
Rate limits prevent notification spam. Current limits: {preferences.maxPerMinute}/min, {preferences.maxPerHour}/hour.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Test Notification */}
|
||||||
|
<View className="mt-2 mb-4">
|
||||||
|
<Button
|
||||||
|
onPress={() => testMutation.mutate()}
|
||||||
|
disabled={!pushEnabled || testMutation.isPending}
|
||||||
|
className={cn(!pushEnabled && 'opacity-50')}
|
||||||
|
>
|
||||||
|
{testMutation.isPending ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.background.dark} />
|
||||||
|
) : (
|
||||||
|
<Text className="text-background font-semibold">Send Test Notification</Text>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Text className="text-xs text-muted-foreground text-center mt-2">
|
||||||
|
Verify that push notifications are working correctly
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
815
apps/mobile/app/user/[id].tsx
Normal file
@@ -0,0 +1,815 @@
|
|||||||
|
/**
|
||||||
|
* User Detail Screen
|
||||||
|
* Shows comprehensive user information with web feature parity
|
||||||
|
* Query keys include selectedServerId for proper cache isolation per media server
|
||||||
|
*/
|
||||||
|
import { View, ScrollView, RefreshControl, Pressable, ActivityIndicator, Image } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
|
||||||
|
import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { formatDistanceToNow, format } from 'date-fns';
|
||||||
|
import {
|
||||||
|
Crown,
|
||||||
|
Play,
|
||||||
|
Clock,
|
||||||
|
AlertTriangle,
|
||||||
|
Globe,
|
||||||
|
MapPin,
|
||||||
|
Smartphone,
|
||||||
|
Monitor,
|
||||||
|
Tv,
|
||||||
|
ChevronRight,
|
||||||
|
Users,
|
||||||
|
Zap,
|
||||||
|
Check,
|
||||||
|
Film,
|
||||||
|
Music,
|
||||||
|
XCircle,
|
||||||
|
User,
|
||||||
|
Bot,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react-native';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api, getServerUrl } from '@/lib/api';
|
||||||
|
import { useMediaServer } from '@/providers/MediaServerProvider';
|
||||||
|
import { Text } from '@/components/ui/text';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
import type {
|
||||||
|
Session,
|
||||||
|
ViolationWithDetails,
|
||||||
|
UserLocation,
|
||||||
|
UserDevice,
|
||||||
|
RuleType,
|
||||||
|
TerminationLogWithDetails,
|
||||||
|
} from '@tracearr/shared';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
// Safe date parsing helper - handles string dates from API
|
||||||
|
function safeParseDate(date: Date | string | null | undefined): Date | null {
|
||||||
|
if (!date) return null;
|
||||||
|
const parsed = new Date(date);
|
||||||
|
return isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe format distance helper
|
||||||
|
function safeFormatDistanceToNow(date: Date | string | null | undefined): string {
|
||||||
|
const parsed = safeParseDate(date);
|
||||||
|
if (!parsed) return 'Unknown';
|
||||||
|
return formatDistanceToNow(parsed, { addSuffix: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe format date helper
|
||||||
|
function safeFormatDate(date: Date | string | null | undefined, formatStr: string): string {
|
||||||
|
const parsed = safeParseDate(date);
|
||||||
|
if (!parsed) return 'Unknown';
|
||||||
|
return format(parsed, formatStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule type icons mapping
|
||||||
|
const ruleIcons: Record<RuleType, LucideIcon> = {
|
||||||
|
impossible_travel: MapPin,
|
||||||
|
simultaneous_locations: Users,
|
||||||
|
device_velocity: Zap,
|
||||||
|
concurrent_streams: Monitor,
|
||||||
|
geo_restriction: Globe,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rule type display names
|
||||||
|
const ruleLabels: Record<RuleType, string> = {
|
||||||
|
impossible_travel: 'Impossible Travel',
|
||||||
|
simultaneous_locations: 'Simultaneous Locations',
|
||||||
|
device_velocity: 'Device Velocity',
|
||||||
|
concurrent_streams: 'Concurrent Streams',
|
||||||
|
geo_restriction: 'Geo Restriction',
|
||||||
|
};
|
||||||
|
|
||||||
|
function TrustScoreBadge({ score, showLabel = false }: { score: number; showLabel?: boolean }) {
|
||||||
|
const variant = score < 50 ? 'destructive' : score < 75 ? 'warning' : 'success';
|
||||||
|
const label = score < 50 ? 'Low' : score < 75 ? 'Medium' : 'High';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<View
|
||||||
|
className={cn(
|
||||||
|
'px-2.5 py-1 rounded-md min-w-[45px] items-center',
|
||||||
|
variant === 'destructive' && 'bg-destructive/20',
|
||||||
|
variant === 'warning' && 'bg-warning/20',
|
||||||
|
variant === 'success' && 'bg-success/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={cn(
|
||||||
|
'text-base font-bold',
|
||||||
|
variant === 'destructive' && 'text-destructive',
|
||||||
|
variant === 'warning' && 'text-warning',
|
||||||
|
variant === 'success' && 'text-success'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{score}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{showLabel && (
|
||||||
|
<Text className="text-sm text-muted-foreground">{label} Trust</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ icon: Icon, label, value, subValue }: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
subValue?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-surface rounded-lg p-3 border border-border">
|
||||||
|
<View className="flex-row items-center gap-2 mb-1">
|
||||||
|
<Icon size={14} color={colors.text.muted.dark} />
|
||||||
|
<Text className="text-xs text-muted-foreground">{label}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-xl font-bold">{value}</Text>
|
||||||
|
{subValue && <Text className="text-xs text-muted-foreground mt-0.5">{subValue}</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeverityBadge({ severity }: { severity: string }) {
|
||||||
|
const variant =
|
||||||
|
severity === 'critical' || severity === 'high'
|
||||||
|
? 'destructive'
|
||||||
|
: severity === 'warning'
|
||||||
|
? 'warning'
|
||||||
|
: 'default';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant={variant} className="capitalize">
|
||||||
|
{severity}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number | null): string {
|
||||||
|
if (!ms) return '-';
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocationCard({ location }: { location: UserLocation }) {
|
||||||
|
const locationText = [location.city, location.region, location.country]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ') || 'Unknown Location';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center gap-3 py-3 border-b border-border">
|
||||||
|
<View className="w-8 h-8 rounded-full bg-cyan-core/10 items-center justify-center">
|
||||||
|
<MapPin size={16} color={colors.cyan.core} />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-medium">{locationText}</Text>
|
||||||
|
<Text className="text-xs text-muted-foreground">
|
||||||
|
{location.sessionCount} {location.sessionCount === 1 ? 'session' : 'sessions'}
|
||||||
|
{' • '}
|
||||||
|
{safeFormatDistanceToNow(location.lastSeenAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceCard({ device }: { device: UserDevice }) {
|
||||||
|
const deviceName = device.playerName || device.device || device.product || 'Unknown Device';
|
||||||
|
const platform = device.platform || 'Unknown Platform';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center gap-3 py-3 border-b border-border">
|
||||||
|
<View className="w-8 h-8 rounded-full bg-cyan-core/10 items-center justify-center">
|
||||||
|
<Smartphone size={16} color={colors.cyan.core} />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-medium">{deviceName}</Text>
|
||||||
|
<Text className="text-xs text-muted-foreground">
|
||||||
|
{platform} • {device.sessionCount} {device.sessionCount === 1 ? 'session' : 'sessions'}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-muted-foreground">
|
||||||
|
Last seen {safeFormatDistanceToNow(device.lastSeenAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMediaIcon(mediaType: string): typeof Film {
|
||||||
|
switch (mediaType) {
|
||||||
|
case 'movie':
|
||||||
|
return Film;
|
||||||
|
case 'episode':
|
||||||
|
return Tv;
|
||||||
|
case 'track':
|
||||||
|
return Music;
|
||||||
|
default:
|
||||||
|
return Film;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionCard({ session, onPress, serverUrl }: { session: Session; onPress?: () => void; serverUrl: string | null }) {
|
||||||
|
const locationText = [session.geoCity, session.geoCountry].filter(Boolean).join(', ');
|
||||||
|
const MediaIcon = getMediaIcon(session.mediaType);
|
||||||
|
|
||||||
|
// Build poster URL - need serverId and thumbPath
|
||||||
|
const hasPoster = serverUrl && session.thumbPath && session.serverId;
|
||||||
|
const posterUrl = hasPoster
|
||||||
|
? `${serverUrl}/api/v1/images/proxy?server=${session.serverId}&url=${encodeURIComponent(session.thumbPath!)}&width=80&height=120`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Determine display state - show "Watched" for completed sessions that reached 80%+
|
||||||
|
const getDisplayState = () => {
|
||||||
|
if (session.watched) return { label: 'Watched', variant: 'success' as const };
|
||||||
|
if (session.state === 'playing') return { label: 'Playing', variant: 'success' as const };
|
||||||
|
if (session.state === 'paused') return { label: 'Paused', variant: 'warning' as const };
|
||||||
|
if (session.state === 'stopped') return { label: 'Stopped', variant: 'secondary' as const };
|
||||||
|
return { label: session.state || 'Unknown', variant: 'secondary' as const };
|
||||||
|
};
|
||||||
|
const displayState = getDisplayState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable onPress={onPress} className="py-3 border-b border-border active:opacity-70">
|
||||||
|
<View className="flex-row">
|
||||||
|
{/* Poster */}
|
||||||
|
<View className="w-10 h-14 rounded-md bg-surface overflow-hidden mr-3">
|
||||||
|
{posterUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: posterUrl }}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className="w-full h-full items-center justify-center">
|
||||||
|
<MediaIcon size={18} color={colors.text.muted.dark} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View className="flex-1">
|
||||||
|
<View className="flex-row justify-between items-start mb-1">
|
||||||
|
<View className="flex-1 mr-2">
|
||||||
|
<Text className="text-sm font-medium" numberOfLines={1}>
|
||||||
|
{session.mediaTitle}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-muted-foreground capitalize">{session.mediaType}</Text>
|
||||||
|
</View>
|
||||||
|
<Badge variant={displayState.variant}>
|
||||||
|
{displayState.label}
|
||||||
|
</Badge>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row items-center gap-4 mt-1">
|
||||||
|
<View className="flex-row items-center gap-1">
|
||||||
|
<Clock size={12} color={colors.text.muted.dark} />
|
||||||
|
<Text className="text-xs text-muted-foreground">{formatDuration(session.durationMs)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row items-center gap-1">
|
||||||
|
<Tv size={12} color={colors.text.muted.dark} />
|
||||||
|
<Text className="text-xs text-muted-foreground">{session.platform || 'Unknown'}</Text>
|
||||||
|
</View>
|
||||||
|
{locationText && (
|
||||||
|
<View className="flex-row items-center gap-1">
|
||||||
|
<Globe size={12} color={colors.text.muted.dark} />
|
||||||
|
<Text className="text-xs text-muted-foreground">{locationText}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViolationCard({
|
||||||
|
violation,
|
||||||
|
onAcknowledge,
|
||||||
|
}: {
|
||||||
|
violation: ViolationWithDetails;
|
||||||
|
onAcknowledge: () => void;
|
||||||
|
}) {
|
||||||
|
const ruleType = violation.rule?.type as RuleType | undefined;
|
||||||
|
const ruleName = ruleType ? ruleLabels[ruleType] : violation.rule?.name || 'Unknown Rule';
|
||||||
|
const IconComponent = ruleType ? ruleIcons[ruleType] : AlertTriangle;
|
||||||
|
const timeAgo = safeFormatDistanceToNow(violation.createdAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="py-3 border-b border-border">
|
||||||
|
<View className="flex-row justify-between items-start mb-2">
|
||||||
|
<View className="flex-row items-center gap-2 flex-1">
|
||||||
|
<View className="w-7 h-7 rounded-md bg-surface items-center justify-center">
|
||||||
|
<IconComponent size={14} color={colors.cyan.core} />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-medium">{ruleName}</Text>
|
||||||
|
<Text className="text-xs text-muted-foreground">{timeAgo}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<SeverityBadge severity={violation.severity} />
|
||||||
|
</View>
|
||||||
|
{!violation.acknowledgedAt ? (
|
||||||
|
<Pressable
|
||||||
|
className="flex-row items-center justify-center gap-1.5 bg-cyan-core/15 py-2 rounded-md mt-2 active:opacity-70"
|
||||||
|
onPress={onAcknowledge}
|
||||||
|
>
|
||||||
|
<Check size={14} color={colors.cyan.core} />
|
||||||
|
<Text className="text-xs font-semibold text-cyan-core">Acknowledge</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<View className="flex-row items-center gap-1.5 mt-2">
|
||||||
|
<Check size={14} color={colors.success} />
|
||||||
|
<Text className="text-xs text-success">Acknowledged</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TerminationCard({ termination }: { termination: TerminationLogWithDetails }) {
|
||||||
|
const timeAgo = safeFormatDistanceToNow(termination.createdAt);
|
||||||
|
const isManual = termination.trigger === 'manual';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="py-3 border-b border-border">
|
||||||
|
<View className="flex-row justify-between items-start mb-2">
|
||||||
|
<View className="flex-row items-center gap-2 flex-1">
|
||||||
|
<View className="w-7 h-7 rounded-md bg-surface items-center justify-center">
|
||||||
|
{isManual ? (
|
||||||
|
<User size={14} color={colors.cyan.core} />
|
||||||
|
) : (
|
||||||
|
<Bot size={14} color={colors.cyan.core} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-medium" numberOfLines={1}>
|
||||||
|
{termination.mediaTitle ?? 'Unknown Media'}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-muted-foreground capitalize">
|
||||||
|
{termination.mediaType ?? 'unknown'} • {timeAgo}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Badge variant={isManual ? 'default' : 'secondary'}>
|
||||||
|
{isManual ? 'Manual' : 'Rule'}
|
||||||
|
</Badge>
|
||||||
|
</View>
|
||||||
|
<View className="ml-9">
|
||||||
|
<Text className="text-xs text-muted-foreground">
|
||||||
|
{isManual
|
||||||
|
? `By @${termination.triggeredByUsername ?? 'Unknown'}`
|
||||||
|
: termination.ruleName ?? 'Unknown rule'}
|
||||||
|
</Text>
|
||||||
|
{termination.reason && (
|
||||||
|
<Text className="text-xs text-muted-foreground mt-1" numberOfLines={2}>
|
||||||
|
Reason: {termination.reason}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View className="flex-row items-center gap-1 mt-1">
|
||||||
|
{termination.success ? (
|
||||||
|
<>
|
||||||
|
<Check size={12} color={colors.success} />
|
||||||
|
<Text className="text-xs text-success">Success</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XCircle size={12} color={colors.error} />
|
||||||
|
<Text className="text-xs text-destructive">Failed</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserDetailScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { selectedServerId } = useMediaServer();
|
||||||
|
const [serverUrl, setServerUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load server URL for image proxy
|
||||||
|
useEffect(() => {
|
||||||
|
void getServerUrl().then(setServerUrl);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch user detail - query keys include selectedServerId for cache isolation
|
||||||
|
const {
|
||||||
|
data: user,
|
||||||
|
isLoading: userLoading,
|
||||||
|
refetch: refetchUser,
|
||||||
|
isRefetching: userRefetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['user', id, selectedServerId],
|
||||||
|
queryFn: () => api.users.get(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update header title with username
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.username) {
|
||||||
|
navigation.setOptions({ title: user.username });
|
||||||
|
}
|
||||||
|
}, [user?.username, navigation]);
|
||||||
|
|
||||||
|
// Fetch user sessions
|
||||||
|
const {
|
||||||
|
data: sessionsData,
|
||||||
|
isLoading: sessionsLoading,
|
||||||
|
fetchNextPage: fetchMoreSessions,
|
||||||
|
hasNextPage: hasMoreSessions,
|
||||||
|
isFetchingNextPage: fetchingMoreSessions,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['user', id, 'sessions', selectedServerId],
|
||||||
|
queryFn: ({ pageParam = 1 }) => api.users.sessions(id, { page: pageParam, pageSize: PAGE_SIZE }),
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage: { page: number; totalPages: number }) => {
|
||||||
|
if (lastPage.page < lastPage.totalPages) {
|
||||||
|
return lastPage.page + 1;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch user violations
|
||||||
|
const {
|
||||||
|
data: violationsData,
|
||||||
|
isLoading: violationsLoading,
|
||||||
|
fetchNextPage: fetchMoreViolations,
|
||||||
|
hasNextPage: hasMoreViolations,
|
||||||
|
isFetchingNextPage: fetchingMoreViolations,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['violations', { userId: id }, selectedServerId],
|
||||||
|
queryFn: ({ pageParam = 1 }) => api.violations.list({ userId: id, page: pageParam, pageSize: PAGE_SIZE }),
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage: { page: number; totalPages: number }) => {
|
||||||
|
if (lastPage.page < lastPage.totalPages) {
|
||||||
|
return lastPage.page + 1;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch user locations
|
||||||
|
const { data: locations, isLoading: locationsLoading } = useQuery({
|
||||||
|
queryKey: ['user', id, 'locations', selectedServerId],
|
||||||
|
queryFn: () => api.users.locations(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch user devices
|
||||||
|
const { data: devices, isLoading: devicesLoading } = useQuery({
|
||||||
|
queryKey: ['user', id, 'devices', selectedServerId],
|
||||||
|
queryFn: () => api.users.devices(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch user terminations
|
||||||
|
const {
|
||||||
|
data: terminationsData,
|
||||||
|
isLoading: terminationsLoading,
|
||||||
|
fetchNextPage: fetchMoreTerminations,
|
||||||
|
hasNextPage: hasMoreTerminations,
|
||||||
|
isFetchingNextPage: fetchingMoreTerminations,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['user', id, 'terminations', selectedServerId],
|
||||||
|
queryFn: ({ pageParam = 1 }) =>
|
||||||
|
api.users.terminations(id, { page: pageParam, pageSize: PAGE_SIZE }),
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage: { page: number; totalPages: number }) => {
|
||||||
|
if (lastPage.page < lastPage.totalPages) {
|
||||||
|
return lastPage.page + 1;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Acknowledge mutation
|
||||||
|
const acknowledgeMutation = useMutation({
|
||||||
|
mutationFn: api.violations.acknowledge,
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['violations', { userId: id }, selectedServerId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessions = sessionsData?.pages.flatMap((page) => page.data) || [];
|
||||||
|
const violations = violationsData?.pages.flatMap((page) => page.data) || [];
|
||||||
|
const terminations = terminationsData?.pages.flatMap((page) => page.data) || [];
|
||||||
|
const totalSessions = sessionsData?.pages[0]?.total || 0;
|
||||||
|
const totalViolations = violationsData?.pages[0]?.total || 0;
|
||||||
|
const totalTerminations = terminationsData?.pages[0]?.total || 0;
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
void refetchUser();
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['user', id, 'sessions', selectedServerId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['violations', { userId: id }, selectedServerId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['user', id, 'locations', selectedServerId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['user', id, 'devices', selectedServerId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['user', id, 'terminations', selectedServerId] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSessionPress = (session: Session) => {
|
||||||
|
router.push(`/session/${session.id}` as never);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (userLoading) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color={colors.cyan.core} />
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
|
||||||
|
<View className="flex-1 items-center justify-center px-8">
|
||||||
|
<Text className="text-xl font-semibold text-center mb-2">User Not Found</Text>
|
||||||
|
<Text className="text-muted-foreground text-center">This user may have been removed.</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerClassName="p-4"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={userRefetching}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
tintColor={colors.cyan.core}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* User Info Card */}
|
||||||
|
<Card className="mb-4">
|
||||||
|
<View className="flex-row items-start gap-4">
|
||||||
|
<UserAvatar
|
||||||
|
thumbUrl={user.thumbUrl}
|
||||||
|
username={user.username}
|
||||||
|
size={64}
|
||||||
|
/>
|
||||||
|
<View className="flex-1">
|
||||||
|
<View className="flex-row items-center gap-2 mb-1">
|
||||||
|
<Text className="text-xl font-bold">{user.username}</Text>
|
||||||
|
{user.role === 'owner' && (
|
||||||
|
<Crown size={18} color={colors.warning} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{user.email && (
|
||||||
|
<Text className="text-sm text-muted-foreground mb-2">{user.email}</Text>
|
||||||
|
)}
|
||||||
|
<TrustScoreBadge score={user.trustScore} showLabel />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<View className="flex-row gap-3 mb-4">
|
||||||
|
<StatCard
|
||||||
|
icon={Play}
|
||||||
|
label="Sessions"
|
||||||
|
value={totalSessions}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={AlertTriangle}
|
||||||
|
label="Violations"
|
||||||
|
value={totalViolations}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row gap-3 mb-4">
|
||||||
|
<StatCard
|
||||||
|
icon={Clock}
|
||||||
|
label="Joined"
|
||||||
|
value={safeFormatDate(user.createdAt, 'MMM d, yyyy')}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Globe}
|
||||||
|
label="Locations"
|
||||||
|
value={locations?.length || 0}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Locations */}
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<CardTitle>Locations</CardTitle>
|
||||||
|
<Text className="text-xs text-muted-foreground">
|
||||||
|
{locations?.length || 0} {locations?.length === 1 ? 'location' : 'locations'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{locationsLoading ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.cyan.core} />
|
||||||
|
) : locations && locations.length > 0 ? (
|
||||||
|
locations.slice(0, 5).map((location, index) => (
|
||||||
|
<LocationCard key={`${location.city}-${location.country}-${index}`} location={location} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text className="text-sm text-muted-foreground py-4 text-center">No locations recorded</Text>
|
||||||
|
)}
|
||||||
|
{locations && locations.length > 5 && (
|
||||||
|
<View className="pt-3 items-center">
|
||||||
|
<Text className="text-xs text-muted-foreground">
|
||||||
|
+{locations.length - 5} more locations
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Devices */}
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<CardTitle>Devices</CardTitle>
|
||||||
|
<Text className="text-xs text-muted-foreground">
|
||||||
|
{devices?.length || 0} {devices?.length === 1 ? 'device' : 'devices'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{devicesLoading ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.cyan.core} />
|
||||||
|
) : devices && devices.length > 0 ? (
|
||||||
|
devices.slice(0, 5).map((device, index) => (
|
||||||
|
<DeviceCard key={device.deviceId || index} device={device} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text className="text-sm text-muted-foreground py-4 text-center">No devices recorded</Text>
|
||||||
|
)}
|
||||||
|
{devices && devices.length > 5 && (
|
||||||
|
<View className="pt-3 items-center">
|
||||||
|
<Text className="text-xs text-muted-foreground">
|
||||||
|
+{devices.length - 5} more devices
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent Sessions */}
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<CardTitle>Recent Sessions</CardTitle>
|
||||||
|
<Text className="text-xs text-muted-foreground">{totalSessions} total</Text>
|
||||||
|
</View>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{sessionsLoading ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.cyan.core} />
|
||||||
|
) : sessions.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
serverUrl={serverUrl}
|
||||||
|
onPress={() => handleSessionPress(session)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{hasMoreSessions && (
|
||||||
|
<Pressable
|
||||||
|
className="py-3 items-center active:opacity-70"
|
||||||
|
onPress={() => void fetchMoreSessions()}
|
||||||
|
disabled={fetchingMoreSessions}
|
||||||
|
>
|
||||||
|
{fetchingMoreSessions ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.cyan.core} />
|
||||||
|
) : (
|
||||||
|
<View className="flex-row items-center gap-1">
|
||||||
|
<Text className="text-sm text-cyan-core font-medium">Load More</Text>
|
||||||
|
<ChevronRight size={16} color={colors.cyan.core} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text className="text-sm text-muted-foreground py-4 text-center">No sessions found</Text>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Violations */}
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardHeader>
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<CardTitle>Violations</CardTitle>
|
||||||
|
<Text className="text-xs text-muted-foreground">{totalViolations} total</Text>
|
||||||
|
</View>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{violationsLoading ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.cyan.core} />
|
||||||
|
) : violations.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{violations.map((violation) => (
|
||||||
|
<ViolationCard
|
||||||
|
key={violation.id}
|
||||||
|
violation={violation}
|
||||||
|
onAcknowledge={() => acknowledgeMutation.mutate(violation.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{hasMoreViolations && (
|
||||||
|
<Pressable
|
||||||
|
className="py-3 items-center active:opacity-70"
|
||||||
|
onPress={() => void fetchMoreViolations()}
|
||||||
|
disabled={fetchingMoreViolations}
|
||||||
|
>
|
||||||
|
{fetchingMoreViolations ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.cyan.core} />
|
||||||
|
) : (
|
||||||
|
<View className="flex-row items-center gap-1">
|
||||||
|
<Text className="text-sm text-cyan-core font-medium">Load More</Text>
|
||||||
|
<ChevronRight size={16} color={colors.cyan.core} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View className="py-4 items-center">
|
||||||
|
<View className="w-12 h-12 rounded-full bg-success/10 items-center justify-center mb-2">
|
||||||
|
<Check size={24} color={colors.success} />
|
||||||
|
</View>
|
||||||
|
<Text className="text-sm text-muted-foreground">No violations</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Termination History */}
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardHeader>
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<XCircle size={18} color={colors.text.primary.dark} />
|
||||||
|
<CardTitle>Termination History</CardTitle>
|
||||||
|
</View>
|
||||||
|
<Text className="text-xs text-muted-foreground">{totalTerminations} total</Text>
|
||||||
|
</View>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{terminationsLoading ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.cyan.core} />
|
||||||
|
) : terminations.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{terminations.map((termination) => (
|
||||||
|
<TerminationCard key={termination.id} termination={termination} />
|
||||||
|
))}
|
||||||
|
{hasMoreTerminations && (
|
||||||
|
<Pressable
|
||||||
|
className="py-3 items-center active:opacity-70"
|
||||||
|
onPress={() => void fetchMoreTerminations()}
|
||||||
|
disabled={fetchingMoreTerminations}
|
||||||
|
>
|
||||||
|
{fetchingMoreTerminations ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.cyan.core} />
|
||||||
|
) : (
|
||||||
|
<View className="flex-row items-center gap-1">
|
||||||
|
<Text className="text-sm text-cyan-core font-medium">Load More</Text>
|
||||||
|
<ChevronRight size={16} color={colors.cyan.core} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text className="text-sm text-muted-foreground py-4 text-center">
|
||||||
|
No stream terminations
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/mobile/app/user/_layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* User detail stack navigator layout
|
||||||
|
* Provides navigation for user detail screens
|
||||||
|
*/
|
||||||
|
import { Stack, useRouter } from 'expo-router';
|
||||||
|
import { Pressable } from 'react-native';
|
||||||
|
import { ChevronLeft } from 'lucide-react-native';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
|
||||||
|
export default function UserLayout() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: true,
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: colors.background.dark,
|
||||||
|
},
|
||||||
|
headerTintColor: colors.text.primary.dark,
|
||||||
|
headerTitleStyle: {
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
headerBackTitle: 'Back',
|
||||||
|
headerLeft: () => (
|
||||||
|
<Pressable onPress={() => router.back()} hitSlop={8}>
|
||||||
|
<ChevronLeft size={28} color={colors.text.primary.dark} />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: colors.background.dark,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="[id]"
|
||||||
|
options={{
|
||||||
|
title: 'User',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
apps/mobile/assets/adaptive-icon-transparent.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
apps/mobile/assets/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 329 KiB |
BIN
apps/mobile/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
apps/mobile/assets/fonts/Inter_500Medium.ttf
Normal file
BIN
apps/mobile/assets/icon-transparent.png
Normal file
|
After Width: | Height: | Size: 510 KiB |
BIN
apps/mobile/assets/icon.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
apps/mobile/assets/logo-transparent.png
Normal file
|
After Width: | Height: | Size: 510 KiB |
BIN
apps/mobile/assets/notification-icon.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
apps/mobile/assets/splash-icon-transparent.png
Normal file
|
After Width: | Height: | Size: 510 KiB |
BIN
apps/mobile/assets/splash-icon.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
19
apps/mobile/babel.config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
plugins: [
|
||||||
|
[
|
||||||
|
'module-resolver',
|
||||||
|
{
|
||||||
|
root: ['.'],
|
||||||
|
alias: {
|
||||||
|
'@': './src',
|
||||||
|
},
|
||||||
|
extensions: ['.ios.js', '.android.js', '.js', '.ts', '.tsx', '.json'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'react-native-reanimated/plugin',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
76
apps/mobile/eas.json
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 12.0.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal",
|
||||||
|
"ios": {
|
||||||
|
"simulator": false
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"ios": {
|
||||||
|
"simulator": false,
|
||||||
|
"autoIncrement": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"buildType": "app-bundle"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"EXPO_PUBLIC_API_URL": "https://tracearr.example.com"
|
||||||
|
},
|
||||||
|
"channel": "preview"
|
||||||
|
},
|
||||||
|
"preview-apk": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
},
|
||||||
|
"channel": "preview"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"resourceClass": "m-medium"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"buildType": "app-bundle"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"EXPO_PUBLIC_API_URL": "https://tracearr.example.com"
|
||||||
|
},
|
||||||
|
"channel": "production",
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"preview": {
|
||||||
|
"ios": {
|
||||||
|
"appleId": "connor.gallopo@me.com",
|
||||||
|
"ascAppId": "6755941553",
|
||||||
|
"appleTeamId": "6DA3FJF5G5"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"serviceAccountKeyPath": "./credentials/google-service-account.json",
|
||||||
|
"track": "internal",
|
||||||
|
"releaseStatus": "draft"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"appleId": "connor.gallopo@me.com",
|
||||||
|
"ascAppId": "6755941553",
|
||||||
|
"appleTeamId": "6DA3FJF5G5"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"serviceAccountKeyPath": "./credentials/google-service-account.json",
|
||||||
|
"track": "production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
apps/mobile/global.css
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
@import "tailwindcss/theme.css" layer(theme);
|
||||||
|
@import "tailwindcss/preflight.css" layer(base);
|
||||||
|
@import "tailwindcss/utilities.css";
|
||||||
|
@import "nativewind/theme";
|
||||||
|
|
||||||
|
/* Tracearr brand colors - matching web dark mode exactly */
|
||||||
|
@theme {
|
||||||
|
/* Brand colors */
|
||||||
|
--color-cyan-core: #18D1E7;
|
||||||
|
--color-cyan-deep: #0EAFC8;
|
||||||
|
--color-cyan-dark: #0A7C96;
|
||||||
|
--color-blue-core: #0B1A2E;
|
||||||
|
--color-blue-steel: #162840;
|
||||||
|
--color-blue-soft: #1E3A5C;
|
||||||
|
|
||||||
|
/* Background colors - matching web dark mode exactly */
|
||||||
|
--color-background: #050A12;
|
||||||
|
--color-card: #0B1A2E;
|
||||||
|
--color-card-foreground: #FFFFFF;
|
||||||
|
--color-surface: #0F2338;
|
||||||
|
--color-popover: #162840;
|
||||||
|
--color-popover-foreground: #FFFFFF;
|
||||||
|
|
||||||
|
/* Text colors */
|
||||||
|
--color-foreground: #FFFFFF;
|
||||||
|
--color-muted: #162840;
|
||||||
|
--color-muted-foreground: #94A3B8;
|
||||||
|
|
||||||
|
/* Primary (cyan) */
|
||||||
|
--color-primary: #18D1E7;
|
||||||
|
--color-primary-foreground: #0B1A2E;
|
||||||
|
|
||||||
|
/* Secondary */
|
||||||
|
--color-secondary: #162840;
|
||||||
|
--color-secondary-foreground: #FFFFFF;
|
||||||
|
|
||||||
|
/* Accent (same as primary) */
|
||||||
|
--color-accent: #18D1E7;
|
||||||
|
--color-accent-foreground: #0B1A2E;
|
||||||
|
|
||||||
|
/* Form inputs */
|
||||||
|
--color-input: #162840;
|
||||||
|
--color-ring: #18D1E7;
|
||||||
|
--color-border: #162840;
|
||||||
|
|
||||||
|
/* Semantic colors */
|
||||||
|
--color-destructive: #EF4444;
|
||||||
|
--color-destructive-foreground: #FFFFFF;
|
||||||
|
--color-success: #22C55E;
|
||||||
|
--color-warning: #F59E0B;
|
||||||
|
--color-danger: #EF4444;
|
||||||
|
|
||||||
|
/* Icon colors */
|
||||||
|
--color-icon: #8CA3B8;
|
||||||
|
--color-icon-active: #18D1E7;
|
||||||
|
--color-icon-danger: #FF4C4C;
|
||||||
|
|
||||||
|
/* Border radius */
|
||||||
|
--radius-lg: 0.5rem;
|
||||||
|
--radius-md: calc(var(--radius-lg) - 2px);
|
||||||
|
--radius-sm: calc(var(--radius-lg) - 4px);
|
||||||
|
}
|
||||||
38
apps/mobile/metro.config.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
const { withNativewind } = require('nativewind/metro');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Find the project and workspace directories
|
||||||
|
const projectRoot = __dirname;
|
||||||
|
const monorepoRoot = path.resolve(projectRoot, '../..');
|
||||||
|
|
||||||
|
const config = getDefaultConfig(projectRoot);
|
||||||
|
|
||||||
|
// 1. Watch all files within the monorepo (include default watchFolders)
|
||||||
|
config.watchFolders = [...(config.watchFolders || []), monorepoRoot];
|
||||||
|
|
||||||
|
// 2. Let Metro know where to resolve packages from
|
||||||
|
config.resolver.nodeModulesPaths = [
|
||||||
|
path.resolve(projectRoot, 'node_modules'),
|
||||||
|
path.resolve(monorepoRoot, 'node_modules'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 3. Enable symlink support for pnpm
|
||||||
|
config.resolver.unstable_enableSymlinks = true;
|
||||||
|
|
||||||
|
// 4. Handle .js imports that should resolve to .ts files (NodeNext compatibility)
|
||||||
|
// TypeScript with moduleResolution: NodeNext requires .js extensions in imports
|
||||||
|
// even for .ts source files. Metro needs help resolving these correctly.
|
||||||
|
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||||
|
if (moduleName.startsWith('.') && moduleName.endsWith('.js')) {
|
||||||
|
const tsModuleName = moduleName.replace(/\.js$/, '.ts');
|
||||||
|
try {
|
||||||
|
return context.resolveRequest(context, tsModuleName, platform);
|
||||||
|
} catch {
|
||||||
|
// Fall through to default resolution if .ts doesn't exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return context.resolveRequest(context, moduleName, platform);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = withNativewind(config, { input: './global.css' });
|
||||||
4
apps/mobile/nativewind-env.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/// <reference types="react-native-css/types" />
|
||||||
|
// NOTE: This file should not be edited and should be committed with your source code.
|
||||||
|
// It is generated by react-native-css. If you need to move or disable this file,
|
||||||
|
// please see the documentation.
|
||||||
86
apps/mobile/package.json
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"name": "@tracearr/mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"dev": "expo start",
|
||||||
|
"lint": "eslint . --ext .ts,.tsx --ignore-pattern '*.config.*' --ignore-pattern '.expo/'",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"build:dev": "eas build --profile development --platform all",
|
||||||
|
"build:dev:ios": "eas build --profile development --platform ios",
|
||||||
|
"build:dev:android": "eas build --profile development --platform android",
|
||||||
|
"build:preview": "eas build --profile preview --platform all",
|
||||||
|
"build:preview:ios": "eas build --profile preview --platform ios",
|
||||||
|
"build:preview:android": "eas build --profile preview --platform android",
|
||||||
|
"build:prod": "eas build --profile production --platform all",
|
||||||
|
"build:prod:ios": "eas build --profile production --platform ios",
|
||||||
|
"build:prod:android": "eas build --profile production --platform android",
|
||||||
|
"submit:ios": "eas submit --platform ios",
|
||||||
|
"submit:android": "eas submit --platform android",
|
||||||
|
"update": "eas update"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo-google-fonts/inter": "^0.4.2",
|
||||||
|
"@expo/metro-runtime": "~6.1.2",
|
||||||
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-navigation/native": "7.1.22",
|
||||||
|
"@shopify/react-native-skia": "^2.2.12",
|
||||||
|
"@tanstack/react-query": "5.60.6",
|
||||||
|
"@tracearr/shared": "workspace:*",
|
||||||
|
"axios": "^1.12.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"expo": "54.0.25",
|
||||||
|
"expo-camera": "17.0.9",
|
||||||
|
"expo-constants": "18.0.10",
|
||||||
|
"expo-dev-client": "~6.0.18",
|
||||||
|
"expo-device": "8.0.9",
|
||||||
|
"expo-font": "^14.0.9",
|
||||||
|
"expo-image": "3.0.10",
|
||||||
|
"expo-linking": "8.0.9",
|
||||||
|
"expo-maps": "^0.12.8",
|
||||||
|
"expo-notifications": "0.32.13",
|
||||||
|
"expo-router": "6.0.15",
|
||||||
|
"expo-secure-store": "15.0.7",
|
||||||
|
"expo-splash-screen": "31.0.11",
|
||||||
|
"expo-status-bar": "3.0.8",
|
||||||
|
"expo-system-ui": "6.0.8",
|
||||||
|
"expo-task-manager": "~14.0.8",
|
||||||
|
"expo-updates": "~29.0.13",
|
||||||
|
"expo-web-browser": "15.0.9",
|
||||||
|
"lucide-react-native": "^0.555.0",
|
||||||
|
"nativewind": "5.0.0-preview.2",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-css": "^3.0.1",
|
||||||
|
"react-native-gesture-handler": "2.28.0",
|
||||||
|
"react-native-quick-crypto": "^1.0.0",
|
||||||
|
"react-native-reanimated": "4.1.5",
|
||||||
|
"react-native-safe-area-context": "5.6.2",
|
||||||
|
"react-native-screens": "4.16.0",
|
||||||
|
"react-native-svg": "15.15.0",
|
||||||
|
"react-native-worklets": "0.5.1",
|
||||||
|
"socket.io-client": "4.8.1",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss": "^4.1.17",
|
||||||
|
"victory-native": "41.20.2",
|
||||||
|
"zustand": "5.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "7.26.0",
|
||||||
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
|
"@types/react": "19.1.17",
|
||||||
|
"babel-plugin-module-resolver": "5.0.2",
|
||||||
|
"babel-preset-expo": "^54.0.7",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "5.9.2"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
26
apps/mobile/plugins/withGradleProperties.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
const { withGradleProperties } = require('expo/config-plugins');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config plugin to customize Android gradle.properties
|
||||||
|
* Used to set JVM memory args for builds with many native dependencies
|
||||||
|
*/
|
||||||
|
module.exports = function withCustomGradleProperties(config, props) {
|
||||||
|
return withGradleProperties(config, (config) => {
|
||||||
|
for (const [key, value] of Object.entries(props)) {
|
||||||
|
const existingIndex = config.modResults.findIndex(
|
||||||
|
(p) => p.type === 'property' && p.key === key
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
config.modResults[existingIndex].value = value;
|
||||||
|
} else {
|
||||||
|
config.modResults.push({
|
||||||
|
type: 'property',
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
};
|
||||||
5
apps/mobile/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
163
apps/mobile/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Error Boundary component for catching and displaying React errors
|
||||||
|
*/
|
||||||
|
import React, { Component, type ReactNode } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
|
||||||
|
import { AlertTriangle, RefreshCw } from 'lucide-react-native';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
errorInfo: React.ErrorInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||||
|
this.setState({ errorInfo });
|
||||||
|
|
||||||
|
// Log error for debugging
|
||||||
|
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||||
|
|
||||||
|
// Call optional error handler
|
||||||
|
this.props.onError?.(error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = (): void => {
|
||||||
|
this.setState({
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
render(): ReactNode {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
// Custom fallback provided
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default error UI
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<AlertTriangle size={48} color={colors.error} strokeWidth={2} />
|
||||||
|
<Text style={styles.title}>Something went wrong</Text>
|
||||||
|
<Text style={styles.message}>
|
||||||
|
An unexpected error occurred. Please try again.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{__DEV__ && this.state.error && (
|
||||||
|
<ScrollView style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorTitle}>Error Details:</Text>
|
||||||
|
<Text style={styles.errorText}>{this.state.error.message}</Text>
|
||||||
|
{this.state.errorInfo?.componentStack && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.errorTitle}>Component Stack:</Text>
|
||||||
|
<Text style={styles.stackText}>
|
||||||
|
{this.state.errorInfo.componentStack}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.button} onPress={this.handleReset}>
|
||||||
|
<RefreshCw size={20} color={colors.text.primary.dark} />
|
||||||
|
<Text style={styles.buttonText}>Try Again</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background.dark,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
alignItems: 'center',
|
||||||
|
maxWidth: 320,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text.primary.dark,
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.text.secondary.dark,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
maxHeight: 200,
|
||||||
|
marginTop: 16,
|
||||||
|
padding: 12,
|
||||||
|
backgroundColor: colors.card.dark,
|
||||||
|
borderRadius: 8,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.error,
|
||||||
|
marginBottom: 4,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.text.secondary.dark,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
},
|
||||||
|
stackText: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
backgroundColor: colors.cyan.core,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text.primary.dark,
|
||||||
|
},
|
||||||
|
});
|
||||||
126
apps/mobile/src/components/ServerSelector.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Server selector component for header
|
||||||
|
* Tappable button that shows current server, opens modal to switch
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Server, ChevronDown, Check } from 'lucide-react-native';
|
||||||
|
import { useMediaServer } from '../providers/MediaServerProvider';
|
||||||
|
import { colors } from '../lib/theme';
|
||||||
|
|
||||||
|
export function ServerSelector() {
|
||||||
|
const { servers, selectedServer, selectedServerId, selectServer, isLoading } = useMediaServer();
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
|
||||||
|
// Don't show if loading or no servers
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center px-3">
|
||||||
|
<ActivityIndicator size="small" color={colors.text.muted.dark} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't show selector if only one server
|
||||||
|
if (servers.length <= 1) {
|
||||||
|
if (servers.length === 1) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center px-3">
|
||||||
|
<Server size={16} color={colors.text.primary.dark} />
|
||||||
|
<Text className="ml-2 text-sm text-white font-medium" numberOfLines={1}>
|
||||||
|
{servers[0]?.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = (serverId: string) => {
|
||||||
|
selectServer(serverId);
|
||||||
|
setModalVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setModalVisible(true)}
|
||||||
|
className="flex-row items-center px-3 py-2"
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Server size={16} color={colors.cyan.core} />
|
||||||
|
<Text className="ml-2 text-sm font-medium text-white" numberOfLines={1}>
|
||||||
|
{selectedServer?.name ?? 'Select Server'}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={16} color={colors.text.muted.dark} className="ml-1" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={modalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
className="flex-1 justify-center items-center bg-black/60"
|
||||||
|
onPress={() => setModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
className="w-4/5 max-w-sm bg-gray-900 rounded-xl overflow-hidden"
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<View className="px-4 py-3 border-b border-gray-800">
|
||||||
|
<Text className="text-lg font-semibold text-white">Select Server</Text>
|
||||||
|
</View>
|
||||||
|
<View className="py-2">
|
||||||
|
{servers.map((server) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={server.id}
|
||||||
|
onPress={() => handleSelect(server.id)}
|
||||||
|
className="flex-row items-center justify-between px-4 py-3"
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center flex-1">
|
||||||
|
<Server
|
||||||
|
size={20}
|
||||||
|
color={
|
||||||
|
server.id === selectedServerId
|
||||||
|
? colors.cyan.core
|
||||||
|
: colors.text.muted.dark
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<View className="ml-3 flex-1">
|
||||||
|
<Text
|
||||||
|
className={`text-base ${
|
||||||
|
server.id === selectedServerId
|
||||||
|
? 'text-cyan-400 font-medium'
|
||||||
|
: 'text-white'
|
||||||
|
}`}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{server.name}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-gray-500 capitalize">
|
||||||
|
{server.type}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{server.id === selectedServerId && (
|
||||||
|
<Check size={20} color={colors.cyan.core} />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
apps/mobile/src/components/charts/DayOfWeekChart.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||||
|
/**
|
||||||
|
* Bar chart showing plays by day of week with touch interaction
|
||||||
|
*/
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { CartesianChart, Bar, useChartPressState } from 'victory-native';
|
||||||
|
import { Circle } from '@shopify/react-native-skia';
|
||||||
|
import { useAnimatedReaction, runOnJS } from 'react-native-reanimated';
|
||||||
|
import type { SharedValue } from 'react-native-reanimated';
|
||||||
|
import { colors, spacing, borderRadius, typography } from '../../lib/theme';
|
||||||
|
import { useChartFont } from './useChartFont';
|
||||||
|
|
||||||
|
interface DayOfWeekChartProps {
|
||||||
|
data: { day: number; name: string; count: number }[];
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_ABBREV = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
|
function ToolTip({ x, y }: { x: SharedValue<number>; y: SharedValue<number> }) {
|
||||||
|
return <Circle cx={x} cy={y} r={5} color={colors.cyan.core} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DayOfWeekChart({ data, height = 180 }: DayOfWeekChartProps) {
|
||||||
|
const font = useChartFont(10);
|
||||||
|
const { state, isActive } = useChartPressState({ x: 0, y: { count: 0 } });
|
||||||
|
|
||||||
|
// React state to display values (synced from SharedValues)
|
||||||
|
const [displayValue, setDisplayValue] = useState<{
|
||||||
|
day: number;
|
||||||
|
count: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Sync SharedValue changes to React state
|
||||||
|
const updateDisplayValue = useCallback(
|
||||||
|
(day: number, count: number) => {
|
||||||
|
setDisplayValue({ day: Math.round(day), count: Math.round(count) });
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearDisplayValue = useCallback(() => {
|
||||||
|
setDisplayValue(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Watch for changes in chart press state
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => ({
|
||||||
|
active: isActive,
|
||||||
|
x: state.x.value.value,
|
||||||
|
y: state.y.count.value.value,
|
||||||
|
}),
|
||||||
|
(current, previous) => {
|
||||||
|
if (current.active) {
|
||||||
|
runOnJS(updateDisplayValue)(current.x, current.y);
|
||||||
|
} else if (previous?.active && !current.active) {
|
||||||
|
runOnJS(clearDisplayValue)();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isActive]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Transform data for victory-native
|
||||||
|
const chartData = data.map((d) => ({
|
||||||
|
x: d.day,
|
||||||
|
count: d.count,
|
||||||
|
name: d.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, styles.emptyContainer, { height }]}>
|
||||||
|
<Text style={styles.emptyText}>No data available</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the selected day name from React state
|
||||||
|
const selectedDay = displayValue
|
||||||
|
? chartData.find((d) => d.x === displayValue.day)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { height }]}>
|
||||||
|
{/* Active value display */}
|
||||||
|
<View style={styles.valueDisplay}>
|
||||||
|
{displayValue && selectedDay ? (
|
||||||
|
<>
|
||||||
|
<Text style={styles.valueText}>{displayValue.count} plays</Text>
|
||||||
|
<Text style={styles.dayText}>{selectedDay.name}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<CartesianChart
|
||||||
|
data={chartData}
|
||||||
|
xKey="x"
|
||||||
|
yKeys={['count']}
|
||||||
|
domainPadding={{ left: 25, right: 25, top: 20 }}
|
||||||
|
chartPressState={state}
|
||||||
|
axisOptions={{
|
||||||
|
font,
|
||||||
|
tickCount: { x: 7, y: 4 },
|
||||||
|
lineColor: colors.border.dark,
|
||||||
|
labelColor: colors.text.muted.dark,
|
||||||
|
formatXLabel: (value) => DAY_ABBREV[Math.round(value)] || '',
|
||||||
|
formatYLabel: (value) => String(Math.round(value)),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ points, chartBounds }) => (
|
||||||
|
<>
|
||||||
|
<Bar
|
||||||
|
points={points.count}
|
||||||
|
chartBounds={chartBounds}
|
||||||
|
color={colors.cyan.core}
|
||||||
|
roundedCorners={{ topLeft: 4, topRight: 4 }}
|
||||||
|
animate={{ type: 'timing', duration: 500 }}
|
||||||
|
/>
|
||||||
|
{isActive && (
|
||||||
|
<ToolTip x={state.x.position} y={state.y.count.position} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CartesianChart>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: colors.card.dark,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: spacing.sm,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
},
|
||||||
|
valueDisplay: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing.xs,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
minHeight: 18,
|
||||||
|
},
|
||||||
|
valueText: {
|
||||||
|
color: colors.cyan.core,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
dayText: {
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
},
|
||||||
|
});
|
||||||
163
apps/mobile/src/components/charts/HourOfDayChart.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||||
|
/**
|
||||||
|
* Bar chart showing plays by hour of day with touch interaction
|
||||||
|
*/
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { CartesianChart, Bar, useChartPressState } from 'victory-native';
|
||||||
|
import { Circle } from '@shopify/react-native-skia';
|
||||||
|
import { useAnimatedReaction, runOnJS } from 'react-native-reanimated';
|
||||||
|
import type { SharedValue } from 'react-native-reanimated';
|
||||||
|
import { colors, spacing, borderRadius, typography } from '../../lib/theme';
|
||||||
|
import { useChartFont } from './useChartFont';
|
||||||
|
|
||||||
|
interface HourOfDayChartProps {
|
||||||
|
data: { hour: number; count: number }[];
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolTip({ x, y }: { x: SharedValue<number>; y: SharedValue<number> }) {
|
||||||
|
return <Circle cx={x} cy={y} r={5} color={colors.purple} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHour(hour: number): string {
|
||||||
|
if (hour === 0) return '12am';
|
||||||
|
if (hour === 12) return '12pm';
|
||||||
|
return hour < 12 ? `${hour}am` : `${hour - 12}pm`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HourOfDayChart({ data, height = 180 }: HourOfDayChartProps) {
|
||||||
|
const font = useChartFont(9);
|
||||||
|
const { state, isActive } = useChartPressState({ x: 0, y: { count: 0 } });
|
||||||
|
|
||||||
|
// React state to display values (synced from SharedValues)
|
||||||
|
const [displayValue, setDisplayValue] = useState<{
|
||||||
|
hour: number;
|
||||||
|
count: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Sync SharedValue changes to React state
|
||||||
|
const updateDisplayValue = useCallback((hour: number, count: number) => {
|
||||||
|
setDisplayValue({ hour: Math.round(hour), count: Math.round(count) });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearDisplayValue = useCallback(() => {
|
||||||
|
setDisplayValue(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Watch for changes in chart press state
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => ({
|
||||||
|
active: isActive,
|
||||||
|
x: state.x.value.value,
|
||||||
|
y: state.y.count.value.value,
|
||||||
|
}),
|
||||||
|
(current, previous) => {
|
||||||
|
if (current.active) {
|
||||||
|
runOnJS(updateDisplayValue)(current.x, current.y);
|
||||||
|
} else if (previous?.active && !current.active) {
|
||||||
|
runOnJS(clearDisplayValue)();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isActive]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Transform data for victory-native
|
||||||
|
const chartData = data.map((d) => ({
|
||||||
|
x: d.hour,
|
||||||
|
count: d.count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, styles.emptyContainer, { height }]}>
|
||||||
|
<Text style={styles.emptyText}>No data available</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { height }]}>
|
||||||
|
{/* Active value display */}
|
||||||
|
<View style={styles.valueDisplay}>
|
||||||
|
{displayValue ? (
|
||||||
|
<>
|
||||||
|
<Text style={styles.valueText}>{displayValue.count} plays</Text>
|
||||||
|
<Text style={styles.hourText}>{formatHour(displayValue.hour)}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<CartesianChart
|
||||||
|
data={chartData}
|
||||||
|
xKey="x"
|
||||||
|
yKeys={['count']}
|
||||||
|
domainPadding={{ left: 10, right: 10, top: 20 }}
|
||||||
|
chartPressState={state}
|
||||||
|
axisOptions={{
|
||||||
|
font,
|
||||||
|
tickCount: { x: 6, y: 4 },
|
||||||
|
lineColor: colors.border.dark,
|
||||||
|
labelColor: colors.text.muted.dark,
|
||||||
|
formatXLabel: (value) => {
|
||||||
|
const hour = Math.round(value);
|
||||||
|
// Only show labels for 0, 6, 12, 18 to avoid crowding
|
||||||
|
if (hour % 6 === 0) {
|
||||||
|
return formatHour(hour);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
formatYLabel: (value) => String(Math.round(value)),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ points, chartBounds }) => (
|
||||||
|
<>
|
||||||
|
<Bar
|
||||||
|
points={points.count}
|
||||||
|
chartBounds={chartBounds}
|
||||||
|
color={colors.purple}
|
||||||
|
roundedCorners={{ topLeft: 2, topRight: 2 }}
|
||||||
|
animate={{ type: 'timing', duration: 500 }}
|
||||||
|
/>
|
||||||
|
{isActive && (
|
||||||
|
<ToolTip x={state.x.position} y={state.y.count.position} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CartesianChart>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: colors.card.dark,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: spacing.sm,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
},
|
||||||
|
valueDisplay: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing.xs,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
minHeight: 18,
|
||||||
|
},
|
||||||
|
valueText: {
|
||||||
|
color: colors.purple,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
hourText: {
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
},
|
||||||
|
});
|
||||||
130
apps/mobile/src/components/charts/PlatformChart.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Donut chart showing plays by platform (matches web implementation)
|
||||||
|
* Note: Touch interactions not yet supported on PolarChart (victory-native issue #252)
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { Pie, PolarChart } from 'victory-native';
|
||||||
|
import { colors, spacing, borderRadius, typography } from '../../lib/theme';
|
||||||
|
|
||||||
|
interface PlatformChartProps {
|
||||||
|
data: { platform: string; count: number }[];
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colors for pie slices - all visible against dark card background
|
||||||
|
const CHART_COLORS = [
|
||||||
|
colors.cyan.core, // #18D1E7 - Cyan
|
||||||
|
colors.info, // #3B82F6 - Bright Blue (not blue.core which matches bg!)
|
||||||
|
colors.success, // #22C55E - Green
|
||||||
|
colors.warning, // #F59E0B - Orange/Yellow
|
||||||
|
colors.purple, // #8B5CF6 - Purple
|
||||||
|
colors.error, // #EF4444 - Red
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PlatformChart({ data }: PlatformChartProps) {
|
||||||
|
// Sort by count and take top 5
|
||||||
|
const sortedData = [...data]
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((d, index) => ({
|
||||||
|
label: d.platform.replace('Plex for ', '').replace('Jellyfin ', ''),
|
||||||
|
value: d.count,
|
||||||
|
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (sortedData.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, styles.emptyContainer]}>
|
||||||
|
<Text style={styles.emptyText}>No platform data available</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = sortedData.reduce((sum, item) => sum + item.value, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Pie Chart */}
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
<PolarChart
|
||||||
|
data={sortedData}
|
||||||
|
labelKey="label"
|
||||||
|
valueKey="value"
|
||||||
|
colorKey="color"
|
||||||
|
>
|
||||||
|
<Pie.Chart
|
||||||
|
innerRadius="50%"
|
||||||
|
circleSweepDegrees={360}
|
||||||
|
startAngle={0}
|
||||||
|
/>
|
||||||
|
</PolarChart>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Legend with percentages */}
|
||||||
|
<View style={styles.legend}>
|
||||||
|
{sortedData.map((item) => (
|
||||||
|
<View key={item.label} style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: item.color }]} />
|
||||||
|
<Text style={styles.legendText} numberOfLines={1}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.legendPercent}>
|
||||||
|
{Math.round((item.value / total) * 100)}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: colors.card.dark,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: spacing.sm,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: 150,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
},
|
||||||
|
chartContainer: {
|
||||||
|
height: 160,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing.md,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border.dark,
|
||||||
|
},
|
||||||
|
legendItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
legendDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
legendText: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
maxWidth: 60,
|
||||||
|
},
|
||||||
|
legendPercent: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.text.secondary.dark,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
});
|
||||||
164
apps/mobile/src/components/charts/PlaysChart.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||||
|
/**
|
||||||
|
* Area chart showing plays over time with touch-to-reveal tooltip
|
||||||
|
*/
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { CartesianChart, Area, useChartPressState } from 'victory-native';
|
||||||
|
import { Circle } from '@shopify/react-native-skia';
|
||||||
|
import { useAnimatedReaction, runOnJS } from 'react-native-reanimated';
|
||||||
|
import type { SharedValue } from 'react-native-reanimated';
|
||||||
|
import { colors, spacing, borderRadius, typography } from '../../lib/theme';
|
||||||
|
import { useChartFont } from './useChartFont';
|
||||||
|
|
||||||
|
interface PlaysChartProps {
|
||||||
|
data: { date: string; count: number }[];
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolTip({ x, y }: { x: SharedValue<number>; y: SharedValue<number> }) {
|
||||||
|
return <Circle cx={x} cy={y} r={6} color={colors.cyan.core} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaysChart({ data, height = 200 }: PlaysChartProps) {
|
||||||
|
const font = useChartFont(10);
|
||||||
|
const { state, isActive } = useChartPressState({ x: 0, y: { count: 0 } });
|
||||||
|
|
||||||
|
// React state to display values (synced from SharedValues)
|
||||||
|
const [displayValue, setDisplayValue] = useState<{
|
||||||
|
index: number;
|
||||||
|
count: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Transform data for victory-native
|
||||||
|
const chartData = data.map((d, index) => ({
|
||||||
|
x: index,
|
||||||
|
count: d.count,
|
||||||
|
label: d.date,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sync SharedValue changes to React state
|
||||||
|
const updateDisplayValue = useCallback((index: number, count: number) => {
|
||||||
|
setDisplayValue({ index: Math.round(index), count: Math.round(count) });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearDisplayValue = useCallback(() => {
|
||||||
|
setDisplayValue(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Watch for changes in chart press state
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => ({
|
||||||
|
active: isActive,
|
||||||
|
x: state.x.value.value,
|
||||||
|
y: state.y.count.value.value,
|
||||||
|
}),
|
||||||
|
(current, previous) => {
|
||||||
|
if (current.active) {
|
||||||
|
runOnJS(updateDisplayValue)(current.x, current.y);
|
||||||
|
} else if (previous?.active && !current.active) {
|
||||||
|
runOnJS(clearDisplayValue)();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isActive]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, styles.emptyContainer, { height }]}>
|
||||||
|
<Text style={styles.emptyText}>No play data available</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get date label from React state
|
||||||
|
const dateLabel = displayValue && chartData[displayValue.index]?.label
|
||||||
|
? new Date(chartData[displayValue.index].label).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { height }]}>
|
||||||
|
{/* Active value display */}
|
||||||
|
<View style={styles.valueDisplay}>
|
||||||
|
{displayValue ? (
|
||||||
|
<>
|
||||||
|
<Text style={styles.valueText}>{displayValue.count} plays</Text>
|
||||||
|
<Text style={styles.dateText}>{dateLabel}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<CartesianChart
|
||||||
|
data={chartData}
|
||||||
|
xKey="x"
|
||||||
|
yKeys={['count']}
|
||||||
|
domainPadding={{ top: 20, bottom: 10, left: 5, right: 5 }}
|
||||||
|
chartPressState={state}
|
||||||
|
axisOptions={{
|
||||||
|
font,
|
||||||
|
tickCount: { x: 5, y: 4 },
|
||||||
|
lineColor: colors.border.dark,
|
||||||
|
labelColor: colors.text.muted.dark,
|
||||||
|
formatXLabel: (value) => {
|
||||||
|
const item = chartData[Math.round(value)];
|
||||||
|
if (!item) return '';
|
||||||
|
const date = new Date(item.label);
|
||||||
|
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||||
|
},
|
||||||
|
formatYLabel: (value) => String(Math.round(value)),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ points, chartBounds }) => (
|
||||||
|
<>
|
||||||
|
<Area
|
||||||
|
points={points.count}
|
||||||
|
y0={chartBounds.bottom}
|
||||||
|
color={colors.cyan.core}
|
||||||
|
opacity={0.6}
|
||||||
|
animate={{ type: 'timing', duration: 500 }}
|
||||||
|
/>
|
||||||
|
{isActive && (
|
||||||
|
<ToolTip x={state.x.position} y={state.y.count.position} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CartesianChart>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: colors.card.dark,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: spacing.sm,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
},
|
||||||
|
valueDisplay: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing.xs,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
minHeight: 20,
|
||||||
|
},
|
||||||
|
valueText: {
|
||||||
|
color: colors.cyan.core,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
},
|
||||||
|
});
|
||||||
107
apps/mobile/src/components/charts/QualityChart.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Simple chart showing direct play vs transcode breakdown
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { colors, spacing, borderRadius, typography } from '../../lib/theme';
|
||||||
|
|
||||||
|
interface QualityChartProps {
|
||||||
|
directPlay: number;
|
||||||
|
transcode: number;
|
||||||
|
directPlayPercent: number;
|
||||||
|
transcodePercent: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QualityChart({
|
||||||
|
directPlay,
|
||||||
|
transcode,
|
||||||
|
directPlayPercent,
|
||||||
|
transcodePercent,
|
||||||
|
height = 120,
|
||||||
|
}: QualityChartProps) {
|
||||||
|
const total = directPlay + transcode;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, styles.emptyContainer, { height }]}>
|
||||||
|
<Text style={styles.emptyText}>No playback data available</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { height }]}>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<View style={styles.barContainer}>
|
||||||
|
<View style={[styles.directBar, { flex: directPlayPercent || 1 }]} />
|
||||||
|
<View style={[styles.transcodeBar, { flex: transcodePercent || 1 }]} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<View style={styles.legend}>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: colors.success }]} />
|
||||||
|
<Text style={styles.legendLabel}>Direct Play</Text>
|
||||||
|
<Text style={styles.legendValue}>{directPlay} ({directPlayPercent}%)</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: colors.warning }]} />
|
||||||
|
<Text style={styles.legendLabel}>Transcode</Text>
|
||||||
|
<Text style={styles.legendValue}>{transcode} ({transcodePercent}%)</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: colors.card.dark,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: spacing.md,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
},
|
||||||
|
barContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
height: 24,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
directBar: {
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
},
|
||||||
|
transcodeBar: {
|
||||||
|
backgroundColor: colors.warning,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
legendItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
legendDot: {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
},
|
||||||
|
legendLabel: {
|
||||||
|
flex: 1,
|
||||||
|
color: colors.text.primary.dark,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
},
|
||||||
|
legendValue: {
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
6
apps/mobile/src/components/charts/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { PlaysChart } from './PlaysChart';
|
||||||
|
export { PlatformChart } from './PlatformChart';
|
||||||
|
export { DayOfWeekChart } from './DayOfWeekChart';
|
||||||
|
export { HourOfDayChart } from './HourOfDayChart';
|
||||||
|
export { QualityChart } from './QualityChart';
|
||||||
|
export { useChartFont } from './useChartFont';
|
||||||
13
apps/mobile/src/components/charts/useChartFont.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Hook to load fonts for chart axis labels
|
||||||
|
* Uses @shopify/react-native-skia's useFont with Inter font
|
||||||
|
*/
|
||||||
|
import { useFont } from '@shopify/react-native-skia';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const InterMedium = require('../../../assets/fonts/Inter_500Medium.ttf') as number;
|
||||||
|
|
||||||
|
export function useChartFont(size: number = 10) {
|
||||||
|
const font = useFont(InterMedium, size);
|
||||||
|
return font;
|
||||||
|
}
|
||||||
207
apps/mobile/src/components/map/StreamMap.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Interactive map showing active stream locations
|
||||||
|
* Uses expo-maps with Apple Maps on iOS, Google Maps on Android
|
||||||
|
*
|
||||||
|
* Note: expo-maps doesn't support custom tile providers, so we can't
|
||||||
|
* match the web's dark theme exactly. Using default map styles.
|
||||||
|
*/
|
||||||
|
import React, { Component, type ReactNode } from 'react';
|
||||||
|
import { View, Text, StyleSheet, Platform } from 'react-native';
|
||||||
|
import { AppleMaps, GoogleMaps } from 'expo-maps';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import type { ActiveSession } from '@tracearr/shared';
|
||||||
|
import { colors, borderRadius, typography } from '../../lib/theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error boundary to catch map crashes (e.g., missing Google Maps API key on Android)
|
||||||
|
* This prevents the entire app from crashing if the map fails to render
|
||||||
|
*/
|
||||||
|
class MapErrorBoundary extends Component<
|
||||||
|
{ children: ReactNode; height: number },
|
||||||
|
{ hasError: boolean; error: Error | null }
|
||||||
|
> {
|
||||||
|
constructor(props: { children: ReactNode; height: number }) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error) {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error('StreamMap crashed:', error.message);
|
||||||
|
console.error('Component stack:', errorInfo.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, styles.errorContainer, { height: this.props.height }]}>
|
||||||
|
<Ionicons name="map-outline" size={32} color={colors.text.muted.dark} />
|
||||||
|
<Text style={styles.errorText}>Map unavailable</Text>
|
||||||
|
{__DEV__ && this.state.error && (
|
||||||
|
<Text style={styles.errorDetail}>{this.state.error.message}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StreamMapProps {
|
||||||
|
sessions: ActiveSession[];
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Session with guaranteed geo coordinates */
|
||||||
|
type SessionWithLocation = ActiveSession & {
|
||||||
|
geoLat: number;
|
||||||
|
geoLon: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Type guard to filter sessions with valid coordinates */
|
||||||
|
function hasLocation(session: ActiveSession): session is SessionWithLocation {
|
||||||
|
return session.geoLat != null && session.geoLon != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StreamMap({ sessions, height = 300 }: StreamMapProps) {
|
||||||
|
// Filter sessions with valid geo coordinates (type guard narrows to SessionWithLocation[])
|
||||||
|
const sessionsWithLocation = sessions.filter(hasLocation);
|
||||||
|
|
||||||
|
if (sessionsWithLocation.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, styles.emptyContainer, { height }]}>
|
||||||
|
<Text style={styles.emptyText}>No location data available</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate center point from all sessions
|
||||||
|
const avgLat = sessionsWithLocation.reduce((sum, s) => sum + s.geoLat, 0) / sessionsWithLocation.length;
|
||||||
|
const avgLon = sessionsWithLocation.reduce((sum, s) => sum + s.geoLon, 0) / sessionsWithLocation.length;
|
||||||
|
|
||||||
|
// Create markers for each session with enhanced info
|
||||||
|
const markers = sessionsWithLocation.map((session) => {
|
||||||
|
const username = session.user?.username || 'Unknown';
|
||||||
|
const location = [session.geoCity, session.geoCountry].filter(Boolean).join(', ') || 'Unknown location';
|
||||||
|
const mediaTitle = session.mediaTitle || 'Unknown';
|
||||||
|
|
||||||
|
// Truncate long media titles for snippet
|
||||||
|
const truncatedTitle = mediaTitle.length > 40
|
||||||
|
? mediaTitle.substring(0, 37) + '...'
|
||||||
|
: mediaTitle;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: session.sessionKey || session.id,
|
||||||
|
coordinates: {
|
||||||
|
latitude: session.geoLat,
|
||||||
|
longitude: session.geoLon,
|
||||||
|
},
|
||||||
|
// Title shows username prominently
|
||||||
|
title: username,
|
||||||
|
// Snippet shows media and location
|
||||||
|
snippet: `${truncatedTitle}\n${location}`,
|
||||||
|
// Use cyan tint to match app theme
|
||||||
|
tintColor: colors.cyan.core,
|
||||||
|
// iOS: Use SF Symbol for streaming indicator
|
||||||
|
...(Platform.OS === 'ios' && {
|
||||||
|
systemImage: 'play.circle.fill',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate appropriate zoom based on marker spread
|
||||||
|
const calculateZoom = () => {
|
||||||
|
if (sessionsWithLocation.length === 1) return 10;
|
||||||
|
|
||||||
|
// Calculate spread of coordinates
|
||||||
|
const lats = sessionsWithLocation.map(s => s.geoLat);
|
||||||
|
const lons = sessionsWithLocation.map(s => s.geoLon);
|
||||||
|
const latSpread = Math.max(...lats) - Math.min(...lats);
|
||||||
|
const lonSpread = Math.max(...lons) - Math.min(...lons);
|
||||||
|
const maxSpread = Math.max(latSpread, lonSpread);
|
||||||
|
|
||||||
|
// Adjust zoom based on spread
|
||||||
|
if (maxSpread > 100) return 2;
|
||||||
|
if (maxSpread > 50) return 3;
|
||||||
|
if (maxSpread > 20) return 4;
|
||||||
|
if (maxSpread > 10) return 5;
|
||||||
|
if (maxSpread > 5) return 6;
|
||||||
|
if (maxSpread > 1) return 8;
|
||||||
|
return 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cameraPosition = {
|
||||||
|
coordinates: {
|
||||||
|
latitude: avgLat || 39.8283,
|
||||||
|
longitude: avgLon || -98.5795,
|
||||||
|
},
|
||||||
|
zoom: calculateZoom(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use platform-specific map component
|
||||||
|
const MapComponent = Platform.OS === 'ios' ? AppleMaps.View : GoogleMaps.View;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapErrorBoundary height={height}>
|
||||||
|
<View style={[styles.container, { height }]}>
|
||||||
|
<MapComponent
|
||||||
|
style={styles.map}
|
||||||
|
cameraPosition={cameraPosition}
|
||||||
|
markers={markers.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
coordinates: m.coordinates,
|
||||||
|
title: m.title,
|
||||||
|
snippet: m.snippet,
|
||||||
|
tintColor: m.tintColor,
|
||||||
|
...(Platform.OS === 'ios' && m.systemImage && { systemImage: m.systemImage }),
|
||||||
|
}))}
|
||||||
|
uiSettings={{
|
||||||
|
compassEnabled: false,
|
||||||
|
scaleBarEnabled: false,
|
||||||
|
rotationGesturesEnabled: false,
|
||||||
|
tiltGesturesEnabled: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</MapErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: colors.card.dark,
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
errorDetail: {
|
||||||
|
color: colors.error,
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
246
apps/mobile/src/components/server/ServerResourceCard.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Server resource monitoring card (CPU + RAM)
|
||||||
|
* Displays real-time server resource utilization with progress bars
|
||||||
|
* Note: Section header is rendered by parent - this is just the card content
|
||||||
|
*/
|
||||||
|
import { View, Animated, StyleSheet } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Text } from '@/components/ui/text';
|
||||||
|
import { colors, spacing, borderRadius, typography } from '@/lib/theme';
|
||||||
|
|
||||||
|
// Bar colors matching web app
|
||||||
|
const BAR_COLORS = {
|
||||||
|
process: '#00b4e4', // Plex-style cyan for "Plex Media Server"
|
||||||
|
system: '#cc7b9f', // Pink/purple for "System"
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ResourceBarProps {
|
||||||
|
label: string;
|
||||||
|
processValue: number;
|
||||||
|
systemValue: number;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourceBar({ label, processValue, systemValue, icon }: ResourceBarProps) {
|
||||||
|
const processWidth = useRef(new Animated.Value(0)).current;
|
||||||
|
const systemWidth = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(processWidth, {
|
||||||
|
toValue: processValue,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}),
|
||||||
|
Animated.timing(systemWidth, {
|
||||||
|
toValue: systemValue,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}, [processValue, systemValue, processWidth, systemWidth]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.resourceBar}>
|
||||||
|
{/* Header row */}
|
||||||
|
<View style={styles.resourceHeader}>
|
||||||
|
<Ionicons name={icon} size={14} color={colors.text.secondary.dark} />
|
||||||
|
<Text style={styles.resourceLabel}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Process bar (Plex Media Server) */}
|
||||||
|
<View style={styles.barSection}>
|
||||||
|
<View style={styles.barLabelRow}>
|
||||||
|
<Text style={styles.barLabelText}>Plex Media Server</Text>
|
||||||
|
<Text style={styles.barValueText}>{processValue}%</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.barTrack}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.barFill,
|
||||||
|
{
|
||||||
|
backgroundColor: BAR_COLORS.process,
|
||||||
|
width: processWidth.interpolate({
|
||||||
|
inputRange: [0, 100],
|
||||||
|
outputRange: ['0%', '100%'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* System bar */}
|
||||||
|
<View style={styles.barSection}>
|
||||||
|
<View style={styles.barLabelRow}>
|
||||||
|
<Text style={styles.barLabelText}>System</Text>
|
||||||
|
<Text style={styles.barValueText}>{systemValue}%</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.barTrack}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.barFill,
|
||||||
|
{
|
||||||
|
backgroundColor: BAR_COLORS.system,
|
||||||
|
width: systemWidth.interpolate({
|
||||||
|
inputRange: [0, 100],
|
||||||
|
outputRange: ['0%', '100%'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerResourceCardProps {
|
||||||
|
latest: {
|
||||||
|
hostCpu: number;
|
||||||
|
processCpu: number;
|
||||||
|
hostMemory: number;
|
||||||
|
processMemory: number;
|
||||||
|
} | null;
|
||||||
|
isLoading?: boolean;
|
||||||
|
error?: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerResourceCard({ latest, isLoading, error }: ServerResourceCardProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={styles.loadingText}>Loading...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<View style={[styles.emptyIconContainer, { backgroundColor: 'rgba(239, 68, 68, 0.1)' }]}>
|
||||||
|
<Ionicons name="alert-circle-outline" size={24} color="#ef4444" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.emptyText}>Failed to load</Text>
|
||||||
|
<Text style={styles.emptySubtext}>{error.message}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!latest) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<View style={styles.emptyIconContainer}>
|
||||||
|
<Ionicons name="server-outline" size={24} color={colors.text.muted.dark} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.emptyText}>No resource data</Text>
|
||||||
|
<Text style={styles.emptySubtext}>Waiting for server statistics...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ResourceBar
|
||||||
|
label="CPU"
|
||||||
|
icon="speedometer-outline"
|
||||||
|
processValue={latest.processCpu}
|
||||||
|
systemValue={latest.hostCpu}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ResourceBar
|
||||||
|
label="RAM"
|
||||||
|
icon="hardware-chip-outline"
|
||||||
|
processValue={latest.processMemory}
|
||||||
|
systemValue={latest.hostMemory}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: colors.card.dark,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: spacing.sm,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
height: 80,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyIconContainer: {
|
||||||
|
backgroundColor: colors.surface.dark,
|
||||||
|
padding: spacing.sm,
|
||||||
|
borderRadius: borderRadius.full,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text.primary.dark,
|
||||||
|
},
|
||||||
|
emptySubtext: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
resourceBar: {
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
resourceHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
resourceLabel: {
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text.primary.dark,
|
||||||
|
},
|
||||||
|
barSection: {
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
barLabelRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 3,
|
||||||
|
},
|
||||||
|
barLabelText: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
},
|
||||||
|
barValueText: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text.primary.dark,
|
||||||
|
},
|
||||||
|
barTrack: {
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: colors.surface.dark,
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
barFill: {
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
256
apps/mobile/src/components/sessions/NowPlayingCard.tsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* Compact card showing an active streaming session
|
||||||
|
* Displays poster, title, user, progress bar, and play/pause status
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Image, Pressable, StyleSheet } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Text } from '@/components/ui/text';
|
||||||
|
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||||
|
import { useAuthStore } from '@/lib/authStore';
|
||||||
|
import { useEstimatedProgress } from '@/hooks/useEstimatedProgress';
|
||||||
|
import { colors, spacing, borderRadius, typography } from '@/lib/theme';
|
||||||
|
import type { ActiveSession } from '@tracearr/shared';
|
||||||
|
|
||||||
|
interface NowPlayingCardProps {
|
||||||
|
session: ActiveSession;
|
||||||
|
onPress?: (session: ActiveSession) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format duration in ms to readable string (HH:MM:SS or MM:SS)
|
||||||
|
*/
|
||||||
|
function formatDuration(ms: number | null): string {
|
||||||
|
if (!ms) return '--:--';
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display title for media (handles TV shows vs movies)
|
||||||
|
*/
|
||||||
|
function getMediaDisplay(session: ActiveSession): { title: string; subtitle: string | null } {
|
||||||
|
if (session.mediaType === 'episode' && session.grandparentTitle) {
|
||||||
|
// TV Show episode
|
||||||
|
const episodeInfo =
|
||||||
|
session.seasonNumber && session.episodeNumber
|
||||||
|
? `S${session.seasonNumber.toString().padStart(2, '0')}E${session.episodeNumber.toString().padStart(2, '0')}`
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
title: session.grandparentTitle,
|
||||||
|
subtitle: episodeInfo ? `${episodeInfo} · ${session.mediaTitle}` : session.mediaTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Movie or music
|
||||||
|
return {
|
||||||
|
title: session.mediaTitle,
|
||||||
|
subtitle: session.year ? `${session.year}` : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NowPlayingCard({ session, onPress }: NowPlayingCardProps) {
|
||||||
|
const { serverUrl } = useAuthStore();
|
||||||
|
const { title, subtitle } = getMediaDisplay(session);
|
||||||
|
|
||||||
|
// Use estimated progress for smooth updates between SSE/poll events
|
||||||
|
const { estimatedProgressMs, progressPercent } = useEstimatedProgress(session);
|
||||||
|
|
||||||
|
// Build poster URL using image proxy
|
||||||
|
const posterUrl =
|
||||||
|
serverUrl && session.thumbPath
|
||||||
|
? `${serverUrl}/api/v1/images/proxy?server=${session.serverId}&url=${encodeURIComponent(session.thumbPath)}&width=80&height=120`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isPaused = session.state === 'paused';
|
||||||
|
const username = session.user?.username || 'Unknown';
|
||||||
|
const userThumbUrl = session.user?.thumbUrl || null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed }) => [styles.container, pressed && styles.pressed]}
|
||||||
|
onPress={() => onPress?.(session)}
|
||||||
|
>
|
||||||
|
{/* Main content row */}
|
||||||
|
<View style={styles.contentRow}>
|
||||||
|
{/* Poster */}
|
||||||
|
<View style={styles.posterContainer}>
|
||||||
|
{posterUrl ? (
|
||||||
|
<Image source={{ uri: posterUrl }} style={styles.poster} resizeMode="cover" />
|
||||||
|
) : (
|
||||||
|
<View style={[styles.poster, styles.posterPlaceholder]}>
|
||||||
|
<Ionicons name="film-outline" size={24} color={colors.text.muted.dark} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{/* Paused overlay */}
|
||||||
|
{isPaused && (
|
||||||
|
<View style={styles.pausedOverlay}>
|
||||||
|
<Ionicons name="pause" size={20} color={colors.text.primary.dark} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Info section */}
|
||||||
|
<View style={styles.info}>
|
||||||
|
{/* Title + subtitle */}
|
||||||
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle && (
|
||||||
|
<Text style={styles.subtitle} numberOfLines={1}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User + time row combined */}
|
||||||
|
<View style={styles.userTimeRow}>
|
||||||
|
<View style={styles.userSection}>
|
||||||
|
<UserAvatar thumbUrl={userThumbUrl} username={username} size={16} />
|
||||||
|
<Text style={styles.username} numberOfLines={1}>
|
||||||
|
{username}
|
||||||
|
</Text>
|
||||||
|
{session.isTranscode && (
|
||||||
|
<Ionicons name="flash" size={10} color={colors.warning} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.timeSection}>
|
||||||
|
<View style={[styles.statusDot, isPaused && styles.statusDotPaused]}>
|
||||||
|
<Ionicons
|
||||||
|
name={isPaused ? 'pause' : 'play'}
|
||||||
|
size={6}
|
||||||
|
color={isPaused ? colors.warning : colors.cyan.core}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.timeText, isPaused && styles.pausedText]}>
|
||||||
|
{isPaused
|
||||||
|
? 'Paused'
|
||||||
|
: `${formatDuration(estimatedProgressMs)} / ${formatDuration(session.totalDurationMs)}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Chevron */}
|
||||||
|
<View style={styles.chevron}>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color={colors.text.muted.dark} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bottom progress bar - full width */}
|
||||||
|
<View style={styles.progressBar}>
|
||||||
|
<View style={[styles.progressFill, { width: `${progressPercent}%` }]} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: colors.card.dark,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
contentRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
},
|
||||||
|
posterContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
},
|
||||||
|
poster: {
|
||||||
|
width: 50,
|
||||||
|
height: 75,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
backgroundColor: colors.surface.dark,
|
||||||
|
},
|
||||||
|
posterPlaceholder: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
pausedOverlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text.primary.dark,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
},
|
||||||
|
userTimeRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
userSection: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.text.secondary.dark,
|
||||||
|
},
|
||||||
|
timeSection: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: 'rgba(24, 209, 231, 0.15)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
statusDotPaused: {
|
||||||
|
backgroundColor: 'rgba(245, 158, 11, 0.15)',
|
||||||
|
},
|
||||||
|
timeText: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
},
|
||||||
|
pausedText: {
|
||||||
|
color: colors.warning,
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: 3,
|
||||||
|
backgroundColor: colors.surface.dark,
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: colors.cyan.core,
|
||||||
|
},
|
||||||
|
chevron: {
|
||||||
|
marginLeft: 4,
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
1
apps/mobile/src/components/sessions/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { NowPlayingCard } from './NowPlayingCard';
|
||||||
55
apps/mobile/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { View, type ViewProps } from 'react-native';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Text } from './text';
|
||||||
|
|
||||||
|
const badgeVariants = cva('flex-row items-center rounded-full px-2.5 py-0.5', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary',
|
||||||
|
secondary: 'bg-secondary',
|
||||||
|
destructive: 'bg-destructive',
|
||||||
|
outline: 'border border-border bg-transparent',
|
||||||
|
success: 'bg-success/15',
|
||||||
|
warning: 'bg-warning/15',
|
||||||
|
danger: 'bg-danger/15',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const badgeTextVariants = cva('text-xs font-semibold', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'text-primary-foreground',
|
||||||
|
secondary: 'text-secondary-foreground',
|
||||||
|
destructive: 'text-destructive-foreground',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
success: 'text-success',
|
||||||
|
warning: 'text-warning',
|
||||||
|
danger: 'text-danger',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface BadgeProps extends ViewProps, VariantProps<typeof badgeVariants> {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Badge: React.FC<BadgeProps> = ({ className, variant, children, ...props }) => (
|
||||||
|
<View className={cn(badgeVariants({ variant, className }))} {...props}>
|
||||||
|
{typeof children === 'string' ? (
|
||||||
|
<Text className={badgeTextVariants({ variant })}>{children}</Text>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { Badge, badgeVariants, badgeTextVariants };
|
||||||
70
apps/mobile/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Pressable, type PressableProps } from 'react-native';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Text } from './text';
|
||||||
|
|
||||||
|
const buttonVariants = cva('flex-row items-center justify-center rounded-md', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary',
|
||||||
|
destructive: 'bg-destructive',
|
||||||
|
outline: 'border border-border bg-transparent',
|
||||||
|
secondary: 'bg-secondary',
|
||||||
|
ghost: 'bg-transparent',
|
||||||
|
link: 'bg-transparent',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 px-3',
|
||||||
|
lg: 'h-11 px-8',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttonTextVariants = cva('font-medium', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'text-primary-foreground',
|
||||||
|
destructive: 'text-destructive-foreground',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
secondary: 'text-secondary-foreground',
|
||||||
|
ghost: 'text-foreground',
|
||||||
|
link: 'text-primary underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'text-sm',
|
||||||
|
sm: 'text-xs',
|
||||||
|
lg: 'text-base',
|
||||||
|
icon: 'text-sm',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ButtonProps extends PressableProps, VariantProps<typeof buttonVariants> {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<React.ComponentRef<typeof Pressable>, ButtonProps>(
|
||||||
|
({ className, variant, size, children, ...props }, ref) => (
|
||||||
|
<Pressable ref={ref} className={cn(buttonVariants({ variant, size, className }))} {...props}>
|
||||||
|
{typeof children === 'string' ? (
|
||||||
|
<Text className={cn(buttonTextVariants({ variant, size }))}>{children}</Text>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants, buttonTextVariants };
|
||||||
40
apps/mobile/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { View, type ViewProps, type Text as RNText, type TextProps } from 'react-native';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Text } from './text';
|
||||||
|
|
||||||
|
const Card = React.forwardRef<View, ViewProps>(({ className, ...props }, ref) => (
|
||||||
|
<View
|
||||||
|
ref={ref}
|
||||||
|
className={cn('rounded-lg border border-border bg-card p-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<View, ViewProps>(({ className, ...props }, ref) => (
|
||||||
|
<View ref={ref} className={cn('pb-2', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<RNText, TextProps>(({ className, ...props }, ref) => (
|
||||||
|
<Text ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<RNText, TextProps>(({ className, ...props }, ref) => (
|
||||||
|
<Text ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<View, ViewProps>(({ className, ...props }, ref) => (
|
||||||
|
<View ref={ref} className={cn('pt-2', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<View, ViewProps>(({ className, ...props }, ref) => (
|
||||||
|
<View ref={ref} className={cn('flex-row items-center pt-4', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||||
4
apps/mobile/src/components/ui/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { Text } from './text';
|
||||||
|
export { Button, buttonVariants, buttonTextVariants } from './button';
|
||||||
|
export { Badge, badgeVariants, badgeTextVariants } from './badge';
|
||||||
|
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card';
|
||||||
68
apps/mobile/src/components/ui/period-selector.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Segmented control for selecting time periods (7d, 30d, 1y)
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Pressable, StyleSheet } from 'react-native';
|
||||||
|
import { Text } from './text';
|
||||||
|
import { colors, spacing, borderRadius } from '@/lib/theme';
|
||||||
|
|
||||||
|
export type StatsPeriod = 'week' | 'month' | 'year';
|
||||||
|
|
||||||
|
interface PeriodSelectorProps {
|
||||||
|
value: StatsPeriod;
|
||||||
|
onChange: (value: StatsPeriod) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERIODS: { value: StatsPeriod; label: string }[] = [
|
||||||
|
{ value: 'week', label: '7d' },
|
||||||
|
{ value: 'month', label: '30d' },
|
||||||
|
{ value: 'year', label: '1y' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{PERIODS.map((period) => {
|
||||||
|
const isSelected = value === period.value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={period.value}
|
||||||
|
onPress={() => onChange(period.value)}
|
||||||
|
style={[styles.button, isSelected && styles.buttonSelected]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.buttonText, isSelected && styles.buttonTextSelected]}
|
||||||
|
>
|
||||||
|
{period.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: colors.surface.dark,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: (spacing.xs as number) + 2,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
},
|
||||||
|
buttonSelected: {
|
||||||
|
backgroundColor: colors.card.dark,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: colors.text.muted.dark,
|
||||||
|
},
|
||||||
|
buttonTextSelected: {
|
||||||
|
color: colors.text.primary.dark,
|
||||||
|
},
|
||||||
|
});
|
||||||
10
apps/mobile/src/components/ui/text.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Text as RNText, type TextProps } from 'react-native';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Text = React.forwardRef<RNText, TextProps>(({ className, ...props }, ref) => (
|
||||||
|
<RNText ref={ref} className={cn('text-foreground text-base', className)} {...props} />
|
||||||
|
));
|
||||||
|
Text.displayName = 'Text';
|
||||||
|
|
||||||
|
export { Text };
|
||||||
68
apps/mobile/src/components/ui/user-avatar.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* User avatar component with image and fallback to initials
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Image, StyleSheet } from 'react-native';
|
||||||
|
import { Text } from './text';
|
||||||
|
import { colors } from '@/lib/theme';
|
||||||
|
|
||||||
|
interface UserAvatarProps {
|
||||||
|
/** User's avatar URL (can be null) */
|
||||||
|
thumbUrl?: string | null;
|
||||||
|
/** Username for generating initials fallback */
|
||||||
|
username: string;
|
||||||
|
/** Size of the avatar (default: 40) */
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserAvatar({ thumbUrl, username, size = 40 }: UserAvatarProps) {
|
||||||
|
const initials = username.slice(0, 2).toUpperCase();
|
||||||
|
const fontSize = Math.max(size * 0.4, 10);
|
||||||
|
const borderRadiusValue = size / 2;
|
||||||
|
|
||||||
|
if (thumbUrl) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
source={{ uri: thumbUrl }}
|
||||||
|
style={[
|
||||||
|
styles.image,
|
||||||
|
{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: borderRadiusValue,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.fallback,
|
||||||
|
{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: borderRadiusValue,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.initials, { fontSize }]}>{initials}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
image: {
|
||||||
|
backgroundColor: colors.surface.dark,
|
||||||
|
},
|
||||||
|
fallback: {
|
||||||
|
backgroundColor: colors.cyan.dark,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
initials: {
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text.primary.dark,
|
||||||
|
},
|
||||||
|
});
|
||||||
76
apps/mobile/src/hooks/useEstimatedProgress.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import type { ActiveSession } from '@tracearr/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that estimates playback progress client-side for smooth UI updates.
|
||||||
|
*
|
||||||
|
* When state is "playing", progress increments every second based on elapsed time.
|
||||||
|
* When state is "paused" or "stopped", progress stays at last known value.
|
||||||
|
*
|
||||||
|
* Resets estimation when:
|
||||||
|
* - Session ID changes
|
||||||
|
* - Server-side progressMs changes (new data from SSE/poll)
|
||||||
|
* - State changes
|
||||||
|
*
|
||||||
|
* @param session - The active session to estimate progress for
|
||||||
|
* @returns Object with estimated progressMs and progress percentage
|
||||||
|
*/
|
||||||
|
export function useEstimatedProgress(session: ActiveSession) {
|
||||||
|
const [estimatedProgressMs, setEstimatedProgressMs] = useState(session.progressMs ?? 0);
|
||||||
|
|
||||||
|
// Track the last known server values to detect changes
|
||||||
|
const lastServerProgress = useRef(session.progressMs);
|
||||||
|
const lastSessionId = useRef(session.id);
|
||||||
|
const lastState = useRef(session.state);
|
||||||
|
const estimationStartTime = useRef(Date.now());
|
||||||
|
const estimationStartProgress = useRef(session.progressMs ?? 0);
|
||||||
|
|
||||||
|
// Reset estimation when server data changes
|
||||||
|
useEffect(() => {
|
||||||
|
const serverProgressChanged = session.progressMs !== lastServerProgress.current;
|
||||||
|
const sessionChanged = session.id !== lastSessionId.current;
|
||||||
|
const stateChanged = session.state !== lastState.current;
|
||||||
|
|
||||||
|
if (sessionChanged || serverProgressChanged || stateChanged) {
|
||||||
|
// Reset to server value
|
||||||
|
const newProgress = session.progressMs ?? 0;
|
||||||
|
setEstimatedProgressMs(newProgress);
|
||||||
|
|
||||||
|
// Update refs
|
||||||
|
lastServerProgress.current = session.progressMs;
|
||||||
|
lastSessionId.current = session.id;
|
||||||
|
lastState.current = session.state;
|
||||||
|
estimationStartTime.current = Date.now();
|
||||||
|
estimationStartProgress.current = newProgress;
|
||||||
|
}
|
||||||
|
}, [session.id, session.progressMs, session.state]);
|
||||||
|
|
||||||
|
// Tick progress when playing
|
||||||
|
useEffect(() => {
|
||||||
|
if (session.state !== 'playing') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
const elapsedMs = Date.now() - estimationStartTime.current;
|
||||||
|
const estimated = estimationStartProgress.current + elapsedMs;
|
||||||
|
|
||||||
|
// Cap at total duration if available
|
||||||
|
const maxProgress = session.totalDurationMs ?? Infinity;
|
||||||
|
setEstimatedProgressMs(Math.min(estimated, maxProgress));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [session.state, session.totalDurationMs]);
|
||||||
|
|
||||||
|
// Calculate percentage
|
||||||
|
const progressPercent = session.totalDurationMs
|
||||||
|
? Math.min((estimatedProgressMs / session.totalDurationMs) * 100, 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
estimatedProgressMs,
|
||||||
|
progressPercent,
|
||||||
|
isEstimating: session.state === 'playing',
|
||||||
|
};
|
||||||
|
}
|
||||||
304
apps/mobile/src/hooks/usePushNotifications.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
/**
|
||||||
|
* Push notifications hook for violation alerts
|
||||||
|
*
|
||||||
|
* Handles push notification registration, foreground notifications,
|
||||||
|
* background task registration, and payload decryption.
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
import * as Device from 'expo-device';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useSocket } from '../providers/SocketProvider';
|
||||||
|
import { useMediaServer } from '../providers/MediaServerProvider';
|
||||||
|
import type { ViolationWithDetails, EncryptedPushPayload } from '@tracearr/shared';
|
||||||
|
import {
|
||||||
|
registerBackgroundNotificationTask,
|
||||||
|
unregisterBackgroundNotificationTask,
|
||||||
|
} from '../lib/backgroundTasks';
|
||||||
|
import { decryptPushPayload, isEncryptionAvailable, getDeviceSecret } from '../lib/crypto';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
|
||||||
|
// Configure notification behavior
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowAlert: true,
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: true,
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if notification payload is encrypted
|
||||||
|
function isEncrypted(data: unknown): data is EncryptedPushPayload {
|
||||||
|
if (!data || typeof data !== 'object') return false;
|
||||||
|
const payload = data as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
payload.v === 1 &&
|
||||||
|
typeof payload.iv === 'string' &&
|
||||||
|
typeof payload.ct === 'string' &&
|
||||||
|
typeof payload.tag === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePushNotifications() {
|
||||||
|
const [expoPushToken, setExpoPushToken] = useState<string | null>(null);
|
||||||
|
const [notification, setNotification] = useState<Notifications.Notification | null>(null);
|
||||||
|
const notificationListener = useRef<Notifications.EventSubscription | null>(null);
|
||||||
|
const responseListener = useRef<Notifications.EventSubscription | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
const { socket } = useSocket();
|
||||||
|
const { selectServer, servers } = useMediaServer();
|
||||||
|
|
||||||
|
// Register for push notifications
|
||||||
|
const registerForPushNotifications = useCallback(async (): Promise<string | null> => {
|
||||||
|
if (!Device.isDevice) {
|
||||||
|
console.log('Push notifications require a physical device');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check existing permissions
|
||||||
|
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
||||||
|
let finalStatus = existingStatus;
|
||||||
|
|
||||||
|
// Request permissions if not granted
|
||||||
|
if (existingStatus !== Notifications.PermissionStatus.GRANTED) {
|
||||||
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
|
finalStatus = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalStatus !== Notifications.PermissionStatus.GRANTED) {
|
||||||
|
console.log('Push notification permission not granted');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Expo push token
|
||||||
|
try {
|
||||||
|
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
|
||||||
|
if (!projectId) {
|
||||||
|
console.error('No EAS project ID found in app config');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const tokenData = await Notifications.getExpoPushTokenAsync({ projectId });
|
||||||
|
return tokenData.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get push token:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Show local notification for violations
|
||||||
|
const showViolationNotification = useCallback(async (violation: ViolationWithDetails) => {
|
||||||
|
const ruleTypeLabels: Record<string, string> = {
|
||||||
|
impossible_travel: 'Impossible Travel',
|
||||||
|
simultaneous_locations: 'Simultaneous Locations',
|
||||||
|
device_velocity: 'Device Velocity',
|
||||||
|
concurrent_streams: 'Concurrent Streams',
|
||||||
|
geo_restriction: 'Geo Restriction',
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityLabels: Record<string, string> = {
|
||||||
|
low: 'Low',
|
||||||
|
warning: 'Warning',
|
||||||
|
high: 'High',
|
||||||
|
critical: 'Critical',
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = `${severityLabels[violation.severity] || 'Warning'} Violation`;
|
||||||
|
const ruleType = violation.rule?.type || '';
|
||||||
|
const body = `${violation.user?.username || 'Unknown user'}: ${ruleTypeLabels[ruleType] || 'Rule Violation'}`;
|
||||||
|
|
||||||
|
await Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
data: {
|
||||||
|
type: 'violation',
|
||||||
|
violationId: violation.id,
|
||||||
|
serverUserId: violation.serverUserId,
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
},
|
||||||
|
trigger: null, // Show immediately
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Process notification data (handle encryption if needed)
|
||||||
|
const processNotificationData = useCallback(
|
||||||
|
async (data: Record<string, unknown>): Promise<Record<string, unknown>> => {
|
||||||
|
if (isEncrypted(data) && isEncryptionAvailable()) {
|
||||||
|
try {
|
||||||
|
return await decryptPushPayload(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to decrypt notification:', error);
|
||||||
|
return data; // Fall back to encrypted data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize push notifications
|
||||||
|
useEffect(() => {
|
||||||
|
const initializePushNotifications = async () => {
|
||||||
|
const token = await registerForPushNotifications();
|
||||||
|
if (token) {
|
||||||
|
setExpoPushToken(token);
|
||||||
|
|
||||||
|
// Register push token with server, including device secret for encryption
|
||||||
|
try {
|
||||||
|
const deviceSecret = isEncryptionAvailable() ? await getDeviceSecret() : undefined;
|
||||||
|
await api.registerPushToken(token, deviceSecret);
|
||||||
|
console.log('Push token registered with server');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to register push token with server:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void initializePushNotifications();
|
||||||
|
|
||||||
|
// Register background notification task
|
||||||
|
void registerBackgroundNotificationTask();
|
||||||
|
|
||||||
|
// Listen for notifications received while app is foregrounded
|
||||||
|
notificationListener.current = Notifications.addNotificationReceivedListener(
|
||||||
|
(receivedNotification) => {
|
||||||
|
// Process/decrypt the notification data if needed
|
||||||
|
const rawData = receivedNotification.request.content.data;
|
||||||
|
if (rawData && typeof rawData === 'object') {
|
||||||
|
void (async () => {
|
||||||
|
const processedData = await processNotificationData(
|
||||||
|
rawData as Record<string, unknown>
|
||||||
|
);
|
||||||
|
// Update the notification with processed data
|
||||||
|
const processedNotification = {
|
||||||
|
...receivedNotification,
|
||||||
|
request: {
|
||||||
|
...receivedNotification.request,
|
||||||
|
content: {
|
||||||
|
...receivedNotification.request.content,
|
||||||
|
data: processedData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setNotification(processedNotification as Notifications.Notification);
|
||||||
|
})();
|
||||||
|
} else {
|
||||||
|
setNotification(receivedNotification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listen for notification taps
|
||||||
|
responseListener.current = Notifications.addNotificationResponseReceivedListener(
|
||||||
|
(response) => {
|
||||||
|
const rawData = response.notification.request.content.data;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
let data = rawData;
|
||||||
|
|
||||||
|
// Decrypt if needed
|
||||||
|
if (rawData && isEncrypted(rawData) && isEncryptionAvailable()) {
|
||||||
|
try {
|
||||||
|
data = await decryptPushPayload(rawData);
|
||||||
|
} catch {
|
||||||
|
// Use raw data if decryption fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-select the server related to this notification if provided
|
||||||
|
const notificationServerId = data?.serverId as string | undefined;
|
||||||
|
if (notificationServerId && servers.some((s) => s.id === notificationServerId)) {
|
||||||
|
selectServer(notificationServerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate based on notification type
|
||||||
|
if (data?.type === 'violation_detected') {
|
||||||
|
router.push('/(tabs)/alerts');
|
||||||
|
} else if (data?.type === 'stream_started' || data?.type === 'stream_stopped') {
|
||||||
|
router.push('/(tabs)/activity');
|
||||||
|
} else if (data?.type === 'server_down' || data?.type === 'server_up') {
|
||||||
|
router.push('/(tabs)');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (notificationListener.current) {
|
||||||
|
notificationListener.current.remove();
|
||||||
|
}
|
||||||
|
if (responseListener.current) {
|
||||||
|
responseListener.current.remove();
|
||||||
|
}
|
||||||
|
// Note: We don't unregister background task on unmount
|
||||||
|
// as it needs to persist for background notifications
|
||||||
|
};
|
||||||
|
}, [registerForPushNotifications, router, processNotificationData, selectServer, servers]);
|
||||||
|
|
||||||
|
// Listen for violation events from socket
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
const handleViolation = (violation: ViolationWithDetails) => {
|
||||||
|
void showViolationNotification(violation);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('violation:new', handleViolation);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('violation:new', handleViolation);
|
||||||
|
};
|
||||||
|
}, [socket, showViolationNotification]);
|
||||||
|
|
||||||
|
// Configure Android notification channels for different notification types
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
// Violations channel - high priority
|
||||||
|
void Notifications.setNotificationChannelAsync('violations', {
|
||||||
|
name: 'Violation Alerts',
|
||||||
|
description: 'Alerts when rule violations are detected',
|
||||||
|
importance: Notifications.AndroidImportance.HIGH,
|
||||||
|
vibrationPattern: [0, 250, 250, 250],
|
||||||
|
lightColor: '#22D3EE',
|
||||||
|
sound: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sessions channel - default priority
|
||||||
|
void Notifications.setNotificationChannelAsync('sessions', {
|
||||||
|
name: 'Stream Activity',
|
||||||
|
description: 'Notifications for stream start/stop events',
|
||||||
|
importance: Notifications.AndroidImportance.DEFAULT,
|
||||||
|
vibrationPattern: [0, 100, 100, 100],
|
||||||
|
lightColor: '#10B981',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alerts channel - high priority (server status)
|
||||||
|
void Notifications.setNotificationChannelAsync('alerts', {
|
||||||
|
name: 'Server Alerts',
|
||||||
|
description: 'Server online/offline notifications',
|
||||||
|
importance: Notifications.AndroidImportance.HIGH,
|
||||||
|
vibrationPattern: [0, 500],
|
||||||
|
lightColor: '#EF4444',
|
||||||
|
sound: 'default',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup function for logout
|
||||||
|
const cleanup = useCallback(async () => {
|
||||||
|
await unregisterBackgroundNotificationTask();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
expoPushToken,
|
||||||
|
notification,
|
||||||
|
showViolationNotification,
|
||||||
|
cleanup,
|
||||||
|
isEncryptionAvailable: isEncryptionAvailable(),
|
||||||
|
};
|
||||||
|
}
|
||||||
126
apps/mobile/src/hooks/useServerStatistics.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Hook for fetching server resource statistics (CPU/RAM)
|
||||||
|
* Only polls when:
|
||||||
|
* 1. App is in foreground (AppState === 'active')
|
||||||
|
* 2. Dashboard tab is focused (useIsFocused)
|
||||||
|
*/
|
||||||
|
import { useRef, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { AppState, type AppStateStatus } from 'react-native';
|
||||||
|
import { useIsFocused } from '@react-navigation/native';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { SERVER_STATS_CONFIG, type ServerResourceDataPoint, type ServerResourceStats } from '@tracearr/shared';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching server resource statistics with fixed 2-minute window
|
||||||
|
* Polls every 6 seconds, displays last 2 minutes of data (20 points)
|
||||||
|
*
|
||||||
|
* @param serverId - Server ID to fetch stats for
|
||||||
|
* @param enabled - Additional enable condition (e.g., server exists)
|
||||||
|
*/
|
||||||
|
export function useServerStatistics(serverId: string | undefined, enabled: boolean = true) {
|
||||||
|
const isFocused = useIsFocused();
|
||||||
|
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState);
|
||||||
|
|
||||||
|
// Track app state changes
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||||
|
setAppState(nextAppState);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Only poll when app is active AND screen is focused
|
||||||
|
const shouldPoll = enabled && !!serverId && appState === 'active' && isFocused;
|
||||||
|
|
||||||
|
// Accumulate data points across polls, keyed by timestamp for deduplication
|
||||||
|
const dataMapRef = useRef<Map<number, ServerResourceDataPoint>>(new Map());
|
||||||
|
|
||||||
|
// Merge new data with existing, keep most recent DATA_POINTS
|
||||||
|
const mergeData = useCallback((newData: ServerResourceDataPoint[]) => {
|
||||||
|
const map = dataMapRef.current;
|
||||||
|
|
||||||
|
// Add/update data points
|
||||||
|
for (const point of newData) {
|
||||||
|
map.set(point.at, point);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by timestamp descending (newest first), keep DATA_POINTS
|
||||||
|
const sorted = Array.from(map.values())
|
||||||
|
.sort((a, b) => b.at - a.at)
|
||||||
|
.slice(0, SERVER_STATS_CONFIG.DATA_POINTS);
|
||||||
|
|
||||||
|
// Rebuild map with only kept points
|
||||||
|
dataMapRef.current = new Map(sorted.map((p) => [p.at, p]));
|
||||||
|
|
||||||
|
// Return in ascending order (oldest first) for chart rendering
|
||||||
|
return sorted.reverse();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const query = useQuery<ServerResourceStats>({
|
||||||
|
queryKey: ['servers', 'statistics', serverId],
|
||||||
|
queryFn: async (): Promise<ServerResourceStats> => {
|
||||||
|
if (!serverId) throw new Error('Server ID required');
|
||||||
|
const response = await api.servers.statistics(serverId);
|
||||||
|
// Merge with accumulated data
|
||||||
|
const mergedData = mergeData(response.data);
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
data: mergedData,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: shouldPoll,
|
||||||
|
// Poll every 6 seconds (matches SERVER_STATS_CONFIG.POLL_INTERVAL_SECONDS)
|
||||||
|
refetchInterval: shouldPoll ? SERVER_STATS_CONFIG.POLL_INTERVAL_SECONDS * 1000 : false,
|
||||||
|
// Keep previous data while fetching new
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
// Data is fresh until next poll
|
||||||
|
staleTime: (SERVER_STATS_CONFIG.POLL_INTERVAL_SECONDS * 1000) - 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate averages from windowed data
|
||||||
|
const dataPoints = query.data?.data;
|
||||||
|
const dataLength = dataPoints?.length ?? 0;
|
||||||
|
const averages = dataPoints && dataLength > 0
|
||||||
|
? {
|
||||||
|
hostCpu: Math.round(
|
||||||
|
dataPoints.reduce((sum: number, p) => sum + p.hostCpuUtilization, 0) / dataLength
|
||||||
|
),
|
||||||
|
processCpu: Math.round(
|
||||||
|
dataPoints.reduce((sum: number, p) => sum + p.processCpuUtilization, 0) / dataLength
|
||||||
|
),
|
||||||
|
hostMemory: Math.round(
|
||||||
|
dataPoints.reduce((sum: number, p) => sum + p.hostMemoryUtilization, 0) / dataLength
|
||||||
|
),
|
||||||
|
processMemory: Math.round(
|
||||||
|
dataPoints.reduce((sum: number, p) => sum + p.processMemoryUtilization, 0) / dataLength
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Get latest values (most recent data point)
|
||||||
|
const lastDataPoint = query.data?.data?.[query.data.data.length - 1];
|
||||||
|
const latest = lastDataPoint
|
||||||
|
? {
|
||||||
|
hostCpu: Math.round(lastDataPoint.hostCpuUtilization),
|
||||||
|
processCpu: Math.round(lastDataPoint.processCpuUtilization),
|
||||||
|
hostMemory: Math.round(lastDataPoint.hostMemoryUtilization),
|
||||||
|
processMemory: Math.round(lastDataPoint.processMemoryUtilization),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
averages,
|
||||||
|
latest,
|
||||||
|
isPolling: shouldPoll,
|
||||||
|
// Provide more specific loading state:
|
||||||
|
// - isLoading: true only on initial fetch with no cached data
|
||||||
|
// - isFetching: true during any fetch (initial or refetch)
|
||||||
|
// We want to show loading UI while waiting for first data
|
||||||
|
isLoadingData: query.isLoading || (query.isFetching && !query.data),
|
||||||
|
};
|
||||||
|
}
|
||||||
545
apps/mobile/src/lib/api.ts
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
/**
|
||||||
|
* API client for Tracearr mobile app
|
||||||
|
* Uses axios with automatic token refresh
|
||||||
|
* Supports multiple servers with active server selection
|
||||||
|
*/
|
||||||
|
import axios from 'axios';
|
||||||
|
import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
import { storage } from './storage';
|
||||||
|
import type {
|
||||||
|
ActiveSession,
|
||||||
|
DashboardStats,
|
||||||
|
ServerUserWithIdentity,
|
||||||
|
ServerUserDetail,
|
||||||
|
Session,
|
||||||
|
SessionWithDetails,
|
||||||
|
UserLocation,
|
||||||
|
UserDevice,
|
||||||
|
Violation,
|
||||||
|
ViolationWithDetails,
|
||||||
|
Rule,
|
||||||
|
Server,
|
||||||
|
Settings,
|
||||||
|
MobilePairResponse,
|
||||||
|
PaginatedResponse,
|
||||||
|
NotificationPreferences,
|
||||||
|
NotificationPreferencesWithStatus,
|
||||||
|
ServerResourceStats,
|
||||||
|
TerminationLogWithDetails,
|
||||||
|
} from '@tracearr/shared';
|
||||||
|
|
||||||
|
// Cache of API clients per server
|
||||||
|
const apiClients = new Map<string, AxiosInstance>();
|
||||||
|
let activeServerId: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize or get the API client for the active server
|
||||||
|
*/
|
||||||
|
export async function getApiClient(): Promise<AxiosInstance> {
|
||||||
|
const serverId = await storage.getActiveServerId();
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error('No server configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If server changed, update active
|
||||||
|
if (activeServerId !== serverId) {
|
||||||
|
activeServerId = serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
const cached = apiClients.get(serverId);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get server info
|
||||||
|
const server = await storage.getServer(serverId);
|
||||||
|
if (!server) {
|
||||||
|
throw new Error('Server not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createApiClient(server.url, serverId);
|
||||||
|
apiClients.set(serverId, client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new API client for a given server
|
||||||
|
*/
|
||||||
|
export function createApiClient(baseURL: string, serverId: string): AxiosInstance {
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: `${baseURL}/api/v1`,
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor - add auth token for this server
|
||||||
|
client.interceptors.request.use(
|
||||||
|
async (config: InternalAxiosRequestConfig) => {
|
||||||
|
const credentials = await storage.getServerCredentials(serverId);
|
||||||
|
if (credentials) {
|
||||||
|
config.headers.Authorization = `Bearer ${credentials.accessToken}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error: unknown) => Promise.reject(error instanceof Error ? error : new Error(String(error)))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor - handle token refresh for this server
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
|
// If 401 and not already retrying, attempt token refresh
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credentials = await storage.getServerCredentials(serverId);
|
||||||
|
if (!credentials?.refreshToken) {
|
||||||
|
throw new Error('No refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await client.post<{ accessToken: string; refreshToken: string }>(
|
||||||
|
'/mobile/refresh',
|
||||||
|
{ refreshToken: credentials.refreshToken }
|
||||||
|
);
|
||||||
|
|
||||||
|
await storage.updateServerTokens(
|
||||||
|
serverId,
|
||||||
|
response.data.accessToken,
|
||||||
|
response.data.refreshToken
|
||||||
|
);
|
||||||
|
|
||||||
|
// Retry original request with new token
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${response.data.accessToken}`;
|
||||||
|
return await client(originalRequest);
|
||||||
|
} catch {
|
||||||
|
// Refresh failed - remove this server's client from cache
|
||||||
|
apiClients.delete(serverId);
|
||||||
|
throw new Error('Session expired');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the API client cache (call when switching servers or logging out)
|
||||||
|
*/
|
||||||
|
export function resetApiClient(): void {
|
||||||
|
apiClients.clear();
|
||||||
|
activeServerId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a specific server's client from cache
|
||||||
|
*/
|
||||||
|
export function removeApiClient(serverId: string): void {
|
||||||
|
apiClients.delete(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current server URL (for building absolute URLs like images)
|
||||||
|
*/
|
||||||
|
export async function getServerUrl(): Promise<string | null> {
|
||||||
|
return storage.getServerUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API methods organized by domain
|
||||||
|
* All methods use the active server's client
|
||||||
|
*/
|
||||||
|
export const api = {
|
||||||
|
/**
|
||||||
|
* Pair with server using mobile token
|
||||||
|
* This is called before we have a client, so it uses direct axios
|
||||||
|
*/
|
||||||
|
pair: async (
|
||||||
|
serverUrl: string,
|
||||||
|
token: string,
|
||||||
|
deviceName: string,
|
||||||
|
deviceId: string,
|
||||||
|
platform: 'ios' | 'android',
|
||||||
|
deviceSecret?: string
|
||||||
|
): Promise<MobilePairResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post<MobilePairResponse>(
|
||||||
|
`${serverUrl}/api/v1/mobile/pair`,
|
||||||
|
{ token, deviceName, deviceId, platform, deviceSecret },
|
||||||
|
{ timeout: 15000 }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
// Extract server's error message if available
|
||||||
|
const serverMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.error;
|
||||||
|
|
||||||
|
if (serverMessage) {
|
||||||
|
throw new Error(serverMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle specific HTTP status codes
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
throw new Error('Too many pairing attempts. Please wait a few minutes.');
|
||||||
|
}
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
throw new Error('Invalid or expired pairing token.');
|
||||||
|
}
|
||||||
|
if (error.response?.status === 400) {
|
||||||
|
throw new Error('Invalid pairing request. Check your token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle network errors
|
||||||
|
if (error.code === 'ECONNABORTED') {
|
||||||
|
throw new Error('Connection timed out. Check your server URL.');
|
||||||
|
}
|
||||||
|
if (error.code === 'ERR_NETWORK' || !error.response) {
|
||||||
|
throw new Error('Cannot reach server. Check URL and network connection.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to axios message
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register push token for notifications
|
||||||
|
*/
|
||||||
|
registerPushToken: async (
|
||||||
|
expoPushToken: string,
|
||||||
|
deviceSecret?: string
|
||||||
|
): Promise<{ success: boolean; updatedSessions: number }> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.post<{ success: boolean; updatedSessions: number }>(
|
||||||
|
'/mobile/push-token',
|
||||||
|
{ expoPushToken, deviceSecret }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard stats
|
||||||
|
*/
|
||||||
|
stats: {
|
||||||
|
dashboard: async (serverId?: string): Promise<DashboardStats> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<DashboardStats>('/stats/dashboard', {
|
||||||
|
params: serverId ? { serverId } : undefined,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
plays: async (params?: {
|
||||||
|
period?: string;
|
||||||
|
serverId?: string;
|
||||||
|
}): Promise<{ data: { date: string; count: number }[] }> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<{ data: { date: string; count: number }[] }>(
|
||||||
|
'/stats/plays',
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
playsByDayOfWeek: async (params?: {
|
||||||
|
period?: string;
|
||||||
|
serverId?: string;
|
||||||
|
}): Promise<{ data: { day: number; name: string; count: number }[] }> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<{ data: { day: number; name: string; count: number }[] }>(
|
||||||
|
'/stats/plays-by-dayofweek',
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
playsByHourOfDay: async (params?: {
|
||||||
|
period?: string;
|
||||||
|
serverId?: string;
|
||||||
|
}): Promise<{ data: { hour: number; count: number }[] }> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<{ data: { hour: number; count: number }[] }>(
|
||||||
|
'/stats/plays-by-hourofday',
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
platforms: async (params?: {
|
||||||
|
period?: string;
|
||||||
|
serverId?: string;
|
||||||
|
}): Promise<{ data: { platform: string; count: number }[] }> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<{ data: { platform: string; count: number }[] }>(
|
||||||
|
'/stats/platforms',
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
quality: async (params?: { period?: string; serverId?: string }): Promise<{
|
||||||
|
directPlay: number;
|
||||||
|
transcode: number;
|
||||||
|
total: number;
|
||||||
|
directPlayPercent: number;
|
||||||
|
transcodePercent: number;
|
||||||
|
}> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<{
|
||||||
|
directPlay: number;
|
||||||
|
transcode: number;
|
||||||
|
total: number;
|
||||||
|
directPlayPercent: number;
|
||||||
|
transcodePercent: number;
|
||||||
|
}>('/stats/quality', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
concurrent: async (params?: {
|
||||||
|
period?: string;
|
||||||
|
serverId?: string;
|
||||||
|
}): Promise<{ data: { hour: string; maxConcurrent: number }[] }> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<{ data: { hour: string; maxConcurrent: number }[] }>(
|
||||||
|
'/stats/concurrent',
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
locations: async (params?: {
|
||||||
|
serverId?: string;
|
||||||
|
userId?: string;
|
||||||
|
}): Promise<{
|
||||||
|
data: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
playCount: number;
|
||||||
|
}[];
|
||||||
|
}> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<{
|
||||||
|
data: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
playCount: number;
|
||||||
|
}[];
|
||||||
|
}>('/stats/locations', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sessions
|
||||||
|
*/
|
||||||
|
sessions: {
|
||||||
|
active: async (serverId?: string): Promise<ActiveSession[]> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<{ data: ActiveSession[] }>('/sessions/active', {
|
||||||
|
params: serverId ? { serverId } : undefined,
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
list: async (params?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
userId?: string;
|
||||||
|
serverId?: string;
|
||||||
|
}) => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<PaginatedResponse<ActiveSession>>('/sessions', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
get: async (id: string): Promise<SessionWithDetails> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<SessionWithDetails>(`/sessions/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
terminate: async (
|
||||||
|
id: string,
|
||||||
|
reason?: string
|
||||||
|
): Promise<{ success: boolean; terminationLogId: string; message: string }> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.post<{
|
||||||
|
success: boolean;
|
||||||
|
terminationLogId: string;
|
||||||
|
message: string;
|
||||||
|
}>(`/mobile/streams/${id}/terminate`, { reason });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Users
|
||||||
|
*/
|
||||||
|
users: {
|
||||||
|
list: async (params?: { page?: number; pageSize?: number; serverId?: string }) => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<PaginatedResponse<ServerUserWithIdentity>>('/users', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
get: async (id: string): Promise<ServerUserDetail> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<ServerUserDetail>(`/users/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
sessions: async (id: string, params?: { page?: number; pageSize?: number }) => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<PaginatedResponse<Session>>(`/users/${id}/sessions`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
locations: async (id: string): Promise<UserLocation[]> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<{ data: UserLocation[] }>(`/users/${id}/locations`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
devices: async (id: string): Promise<UserDevice[]> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<{ data: UserDevice[] }>(`/users/${id}/devices`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
terminations: async (
|
||||||
|
id: string,
|
||||||
|
params?: { page?: number; pageSize?: number }
|
||||||
|
): Promise<PaginatedResponse<TerminationLogWithDetails>> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<PaginatedResponse<TerminationLogWithDetails>>(
|
||||||
|
`/users/${id}/terminations`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Violations
|
||||||
|
*/
|
||||||
|
violations: {
|
||||||
|
list: async (params?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
userId?: string;
|
||||||
|
severity?: string;
|
||||||
|
acknowledged?: boolean;
|
||||||
|
serverId?: string;
|
||||||
|
}) => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<PaginatedResponse<ViolationWithDetails>>('/violations', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
acknowledge: async (id: string): Promise<Violation> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.patch<Violation>(`/violations/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
dismiss: async (id: string): Promise<void> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
await client.delete(`/violations/${id}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rules
|
||||||
|
*/
|
||||||
|
rules: {
|
||||||
|
list: async (serverId?: string): Promise<Rule[]> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<{ data: Rule[] }>('/rules', {
|
||||||
|
params: serverId ? { serverId } : undefined,
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
toggle: async (id: string, isActive: boolean): Promise<Rule> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.patch<Rule>(`/rules/${id}`, { isActive });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servers
|
||||||
|
*/
|
||||||
|
servers: {
|
||||||
|
list: async (): Promise<Server[]> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<{ data: Server[] }>('/servers');
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
statistics: async (id: string): Promise<ServerResourceStats> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<ServerResourceStats>(`/servers/${id}/statistics`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification preferences (per-device settings)
|
||||||
|
*/
|
||||||
|
notifications: {
|
||||||
|
/**
|
||||||
|
* Get notification preferences for current device
|
||||||
|
* Returns preferences with live rate limit status from Redis
|
||||||
|
*/
|
||||||
|
getPreferences: async (): Promise<NotificationPreferencesWithStatus> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<NotificationPreferencesWithStatus>(
|
||||||
|
'/notifications/preferences'
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update notification preferences for current device
|
||||||
|
* Supports partial updates - only send fields you want to change
|
||||||
|
*/
|
||||||
|
updatePreferences: async (
|
||||||
|
data: Partial<
|
||||||
|
Omit<NotificationPreferences, 'id' | 'mobileSessionId' | 'createdAt' | 'updatedAt'>
|
||||||
|
>
|
||||||
|
): Promise<NotificationPreferences> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.patch<NotificationPreferences>(
|
||||||
|
'/notifications/preferences',
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a test notification to verify push is working
|
||||||
|
*/
|
||||||
|
sendTest: async (): Promise<{ success: boolean; message: string }> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.post<{ success: boolean; message: string }>(
|
||||||
|
'/notifications/test'
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global settings (display preferences, etc.)
|
||||||
|
*/
|
||||||
|
settings: {
|
||||||
|
get: async (): Promise<Settings> => {
|
||||||
|
const client = await getApiClient();
|
||||||
|
const response = await client.get<Settings>('/settings');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
308
apps/mobile/src/lib/authStore.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* Authentication state store using Zustand
|
||||||
|
* Supports multiple server connections with active server selection
|
||||||
|
*/
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { storage, type ServerInfo } from './storage';
|
||||||
|
import { api, resetApiClient } from './api';
|
||||||
|
import * as Device from 'expo-device';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import { isEncryptionAvailable, getDeviceSecret } from './crypto';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
// Multi-server state
|
||||||
|
servers: ServerInfo[];
|
||||||
|
activeServerId: string | null;
|
||||||
|
activeServer: ServerInfo | null;
|
||||||
|
|
||||||
|
// Legacy compatibility
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
serverUrl: string | null;
|
||||||
|
serverName: string | null;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
initialize: () => Promise<void>;
|
||||||
|
pair: (serverUrl: string, token: string) => Promise<void>;
|
||||||
|
addServer: (serverUrl: string, token: string) => Promise<void>;
|
||||||
|
removeServer: (serverId: string) => Promise<void>;
|
||||||
|
selectServer: (serverId: string) => Promise<void>;
|
||||||
|
/** @deprecated Use removeServer(serverId) instead for clarity. This removes the active server. */
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
removeActiveServer: () => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
servers: [],
|
||||||
|
activeServerId: null,
|
||||||
|
activeServer: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: true,
|
||||||
|
serverUrl: null,
|
||||||
|
serverName: null,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize auth state from stored credentials
|
||||||
|
* Handles migration from legacy single-server storage
|
||||||
|
*/
|
||||||
|
initialize: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
// Check for and migrate legacy storage
|
||||||
|
await storage.migrateFromLegacy();
|
||||||
|
|
||||||
|
// Load servers and active selection
|
||||||
|
const servers = await storage.getServers();
|
||||||
|
const activeServerId = await storage.getActiveServerId();
|
||||||
|
const activeServer = activeServerId
|
||||||
|
? servers.find((s) => s.id === activeServerId) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// If we have servers but no active selection, select first one
|
||||||
|
if (servers.length > 0 && !activeServer) {
|
||||||
|
const firstServer = servers[0]!;
|
||||||
|
await storage.setActiveServerId(firstServer.id);
|
||||||
|
set({
|
||||||
|
servers,
|
||||||
|
activeServerId: firstServer.id,
|
||||||
|
activeServer: firstServer,
|
||||||
|
isAuthenticated: true,
|
||||||
|
serverUrl: firstServer.url,
|
||||||
|
serverName: firstServer.name,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} else if (activeServer) {
|
||||||
|
set({
|
||||||
|
servers,
|
||||||
|
activeServerId,
|
||||||
|
activeServer,
|
||||||
|
isAuthenticated: true,
|
||||||
|
serverUrl: activeServer.url,
|
||||||
|
serverName: activeServer.name,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
servers: [],
|
||||||
|
activeServerId: null,
|
||||||
|
activeServer: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
serverUrl: null,
|
||||||
|
serverName: null,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth initialization failed:', error);
|
||||||
|
set({
|
||||||
|
servers: [],
|
||||||
|
activeServerId: null,
|
||||||
|
activeServer: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: 'Failed to initialize authentication',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair with server using mobile token (legacy method, adds as first/only server)
|
||||||
|
*/
|
||||||
|
pair: async (serverUrl: string, token: string) => {
|
||||||
|
// Delegate to addServer
|
||||||
|
await get().addServer(serverUrl, token);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new server connection
|
||||||
|
*/
|
||||||
|
addServer: async (serverUrl: string, token: string) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
// Get device info
|
||||||
|
const deviceName =
|
||||||
|
Device.deviceName || `${Device.brand || 'Unknown'} ${Device.modelName || 'Device'}`;
|
||||||
|
const deviceId = Device.osBuildId || `${Platform.OS}-${Date.now()}`;
|
||||||
|
const platform = Platform.OS === 'ios' ? 'ios' : 'android';
|
||||||
|
|
||||||
|
// Normalize URL (remove trailing slash)
|
||||||
|
const normalizedUrl = serverUrl.replace(/\/$/, '');
|
||||||
|
|
||||||
|
// Get device secret for push notification encryption (if available)
|
||||||
|
let deviceSecret: string | undefined;
|
||||||
|
if (isEncryptionAvailable()) {
|
||||||
|
try {
|
||||||
|
deviceSecret = await getDeviceSecret();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to get device secret for encryption:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call pair API
|
||||||
|
const response = await api.pair(
|
||||||
|
normalizedUrl,
|
||||||
|
token,
|
||||||
|
deviceName,
|
||||||
|
deviceId,
|
||||||
|
platform,
|
||||||
|
deviceSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create server info
|
||||||
|
const serverInfo: ServerInfo = {
|
||||||
|
id: response.server.id,
|
||||||
|
url: normalizedUrl,
|
||||||
|
name: response.server.name,
|
||||||
|
type: response.server.type,
|
||||||
|
addedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store server and credentials
|
||||||
|
await storage.addServer(serverInfo, {
|
||||||
|
accessToken: response.accessToken,
|
||||||
|
refreshToken: response.refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set as active server
|
||||||
|
await storage.setActiveServerId(serverInfo.id);
|
||||||
|
|
||||||
|
// Reset API client to use new server
|
||||||
|
resetApiClient();
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
const servers = await storage.getServers();
|
||||||
|
set({
|
||||||
|
servers,
|
||||||
|
activeServerId: serverInfo.id,
|
||||||
|
activeServer: serverInfo,
|
||||||
|
isAuthenticated: true,
|
||||||
|
serverUrl: normalizedUrl,
|
||||||
|
serverName: serverInfo.name,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Adding server failed:', error);
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to add server. Check URL and token.',
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a server connection
|
||||||
|
*/
|
||||||
|
removeServer: async (serverId: string) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true });
|
||||||
|
|
||||||
|
await storage.removeServer(serverId);
|
||||||
|
|
||||||
|
// Reload state
|
||||||
|
const servers = await storage.getServers();
|
||||||
|
const activeServerId = await storage.getActiveServerId();
|
||||||
|
const activeServer = activeServerId
|
||||||
|
? servers.find((s) => s.id === activeServerId) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Reset API client
|
||||||
|
resetApiClient();
|
||||||
|
|
||||||
|
if (servers.length === 0) {
|
||||||
|
set({
|
||||||
|
servers: [],
|
||||||
|
activeServerId: null,
|
||||||
|
activeServer: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
serverUrl: null,
|
||||||
|
serverName: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
servers,
|
||||||
|
activeServerId,
|
||||||
|
activeServer,
|
||||||
|
isAuthenticated: true,
|
||||||
|
serverUrl: activeServer?.url ?? null,
|
||||||
|
serverName: activeServer?.name ?? null,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Removing server failed:', error);
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
error: 'Failed to remove server',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a different server
|
||||||
|
*/
|
||||||
|
selectServer: async (serverId: string) => {
|
||||||
|
try {
|
||||||
|
const { servers } = get();
|
||||||
|
const server = servers.find((s) => s.id === serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new Error('Server not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set as active
|
||||||
|
await storage.setActiveServerId(serverId);
|
||||||
|
|
||||||
|
// Reset API client to use new server
|
||||||
|
resetApiClient();
|
||||||
|
|
||||||
|
set({
|
||||||
|
activeServerId: serverId,
|
||||||
|
activeServer: server,
|
||||||
|
serverUrl: server.url,
|
||||||
|
serverName: server.name,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Selecting server failed:', error);
|
||||||
|
set({
|
||||||
|
error: 'Failed to switch server',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the currently active server
|
||||||
|
* @deprecated Use removeServer(serverId) instead for clarity
|
||||||
|
*/
|
||||||
|
logout: async () => {
|
||||||
|
const { activeServerId } = get();
|
||||||
|
if (activeServerId) {
|
||||||
|
await get().removeServer(activeServerId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the currently active server (alias for logout with clearer name)
|
||||||
|
*/
|
||||||
|
removeActiveServer: async () => {
|
||||||
|
const { activeServerId } = get();
|
||||||
|
if (activeServerId) {
|
||||||
|
await get().removeServer(activeServerId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear error message
|
||||||
|
*/
|
||||||
|
clearError: () => {
|
||||||
|
set({ error: null });
|
||||||
|
},
|
||||||
|
}));
|
||||||
186
apps/mobile/src/lib/backgroundTasks.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Background Task Handler for Push Notifications
|
||||||
|
*
|
||||||
|
* Handles push notifications when the app is in the background or killed.
|
||||||
|
* Uses expo-task-manager to register background tasks that process
|
||||||
|
* incoming notifications.
|
||||||
|
*/
|
||||||
|
import * as TaskManager from 'expo-task-manager';
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
import { decryptPushPayload, type DecryptedPayload } from './crypto';
|
||||||
|
import type { EncryptedPushPayload } from '@tracearr/shared';
|
||||||
|
|
||||||
|
// Task identifier for background notification handling
|
||||||
|
export const BACKGROUND_NOTIFICATION_TASK = 'BACKGROUND_NOTIFICATION_TASK';
|
||||||
|
|
||||||
|
// Check if notification payload is encrypted
|
||||||
|
function isEncrypted(data: unknown): data is EncryptedPushPayload {
|
||||||
|
if (!data || typeof data !== 'object') return false;
|
||||||
|
const payload = data as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
payload.v === 1 &&
|
||||||
|
typeof payload.iv === 'string' &&
|
||||||
|
typeof payload.ct === 'string' &&
|
||||||
|
typeof payload.tag === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process notification payload (decrypt if needed)
|
||||||
|
*/
|
||||||
|
async function processPayload(
|
||||||
|
data: Record<string, unknown>
|
||||||
|
): Promise<DecryptedPayload | null> {
|
||||||
|
// Check if payload is encrypted
|
||||||
|
if (isEncrypted(data)) {
|
||||||
|
try {
|
||||||
|
return await decryptPushPayload(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BackgroundTask] Failed to decrypt payload:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not encrypted, return as-is
|
||||||
|
return data as DecryptedPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle different notification types in background
|
||||||
|
*/
|
||||||
|
async function handleNotificationType(payload: DecryptedPayload): Promise<void> {
|
||||||
|
const notificationType = payload.type as string;
|
||||||
|
|
||||||
|
// Handle based on notification type
|
||||||
|
if (notificationType === 'violation_detected') {
|
||||||
|
// Violation notifications are critical - ensure they're displayed
|
||||||
|
console.log('[BackgroundTask] Processing violation notification');
|
||||||
|
} else if (
|
||||||
|
notificationType === 'stream_started' ||
|
||||||
|
notificationType === 'stream_stopped'
|
||||||
|
) {
|
||||||
|
// Session notifications are informational
|
||||||
|
console.log('[BackgroundTask] Processing session notification');
|
||||||
|
} else if (
|
||||||
|
notificationType === 'server_down' ||
|
||||||
|
notificationType === 'server_up'
|
||||||
|
) {
|
||||||
|
// Server status notifications are important
|
||||||
|
console.log('[BackgroundTask] Processing server status notification');
|
||||||
|
} else if (notificationType === 'data_sync') {
|
||||||
|
// Silent notification for background data refresh
|
||||||
|
const syncType = payload.syncType as string | undefined;
|
||||||
|
console.log('[BackgroundTask] Processing data sync request:', syncType);
|
||||||
|
|
||||||
|
// Import QueryClient dynamically to avoid circular dependencies
|
||||||
|
try {
|
||||||
|
// Invalidate relevant query caches based on sync type
|
||||||
|
// The actual data will be refetched when the app becomes active
|
||||||
|
// This is handled by React Query's cache invalidation
|
||||||
|
if (syncType === 'stats') {
|
||||||
|
console.log('[BackgroundTask] Marking stats cache for refresh');
|
||||||
|
// Stats will be refetched on next app focus
|
||||||
|
} else if (syncType === 'sessions') {
|
||||||
|
console.log('[BackgroundTask] Marking sessions cache for refresh');
|
||||||
|
// Sessions will be refetched on next app focus
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BackgroundTask] Data sync error:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[BackgroundTask] Unknown notification type:', notificationType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the background notification task
|
||||||
|
*
|
||||||
|
* This task is executed by the OS when a background notification arrives.
|
||||||
|
* It must complete within the OS-defined time limit (usually 30 seconds).
|
||||||
|
*/
|
||||||
|
TaskManager.defineTask(
|
||||||
|
BACKGROUND_NOTIFICATION_TASK,
|
||||||
|
async ({ data, error }: TaskManager.TaskManagerTaskBody) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('[BackgroundTask] Error:', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
console.log('[BackgroundTask] No data received');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract notification from task data
|
||||||
|
const taskData = data as { notification?: Notifications.Notification };
|
||||||
|
const notificationData = taskData.notification?.request.content.data;
|
||||||
|
|
||||||
|
if (!notificationData) {
|
||||||
|
console.log('[BackgroundTask] No notification data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the payload (decrypt if encrypted)
|
||||||
|
const payload = await processPayload(
|
||||||
|
notificationData as Record<string, unknown>
|
||||||
|
);
|
||||||
|
if (!payload) {
|
||||||
|
console.error('[BackgroundTask] Failed to process payload');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the notification based on type
|
||||||
|
await handleNotificationType(payload);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BackgroundTask] Processing error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the background notification task
|
||||||
|
* Call this during app initialization
|
||||||
|
*/
|
||||||
|
export async function registerBackgroundNotificationTask(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const isRegistered = await TaskManager.isTaskRegisteredAsync(
|
||||||
|
BACKGROUND_NOTIFICATION_TASK
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isRegistered) {
|
||||||
|
await Notifications.registerTaskAsync(BACKGROUND_NOTIFICATION_TASK);
|
||||||
|
console.log('[BackgroundTask] Registered background notification task');
|
||||||
|
} else {
|
||||||
|
console.log('[BackgroundTask] Background task already registered');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BackgroundTask] Failed to register task:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister the background notification task
|
||||||
|
* Call this on logout/cleanup
|
||||||
|
*/
|
||||||
|
export async function unregisterBackgroundNotificationTask(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const isRegistered = await TaskManager.isTaskRegisteredAsync(
|
||||||
|
BACKGROUND_NOTIFICATION_TASK
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isRegistered) {
|
||||||
|
await Notifications.unregisterTaskAsync(BACKGROUND_NOTIFICATION_TASK);
|
||||||
|
console.log('[BackgroundTask] Unregistered background notification task');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BackgroundTask] Failed to unregister task:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if background notifications are supported
|
||||||
|
*/
|
||||||
|
export function isBackgroundNotificationSupported(): boolean {
|
||||||
|
return TaskManager.isAvailableAsync !== undefined;
|
||||||
|
}
|
||||||
203
apps/mobile/src/lib/crypto.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Push Notification Payload Encryption/Decryption
|
||||||
|
*
|
||||||
|
* Uses AES-256-GCM (Authenticated Encryption with Associated Data)
|
||||||
|
* for secure push notification payloads.
|
||||||
|
*
|
||||||
|
* Security properties:
|
||||||
|
* - Confidentiality: Only the intended device can read the payload
|
||||||
|
* - Integrity: Tampered payloads are detected and rejected
|
||||||
|
* - Per-device keys: Each device has a unique derived key
|
||||||
|
*/
|
||||||
|
import crypto from 'react-native-quick-crypto';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import type { EncryptedPushPayload, NotificationEventType } from '@tracearr/shared';
|
||||||
|
|
||||||
|
// Storage key for the per-device encryption secret
|
||||||
|
const DEVICE_SECRET_KEY = 'tracearr_device_secret';
|
||||||
|
|
||||||
|
// AES-256-GCM parameters
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
const KEY_LENGTH = 32; // 256 bits
|
||||||
|
const IV_LENGTH = 12; // 96 bits (recommended for GCM)
|
||||||
|
const SALT_LENGTH = 16; // 128 bits (NIST recommended minimum)
|
||||||
|
const AUTH_TAG_LENGTH = 16; // 128 bits
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypted push payload structure
|
||||||
|
*/
|
||||||
|
export interface DecryptedPayload {
|
||||||
|
type: NotificationEventType | 'data_sync';
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate or retrieve the device-specific encryption secret
|
||||||
|
* This secret is used along with the server's key to derive the encryption key
|
||||||
|
*/
|
||||||
|
export async function getDeviceSecret(): Promise<string> {
|
||||||
|
let secret = await SecureStore.getItemAsync(DEVICE_SECRET_KEY);
|
||||||
|
|
||||||
|
if (!secret) {
|
||||||
|
// Generate a new 32-byte random secret
|
||||||
|
const randomBytes = crypto.randomBytes(32);
|
||||||
|
secret = Buffer.from(randomBytes).toString('base64');
|
||||||
|
await SecureStore.setItemAsync(DEVICE_SECRET_KEY, secret);
|
||||||
|
console.log('[Crypto] Generated new device secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the encryption key using PBKDF2
|
||||||
|
*
|
||||||
|
* The key is derived from:
|
||||||
|
* - Device secret (stored locally)
|
||||||
|
* - Server key identifier (sent with encrypted payload)
|
||||||
|
*
|
||||||
|
* This ensures each device has a unique key.
|
||||||
|
*/
|
||||||
|
export async function deriveKey(
|
||||||
|
deviceSecret: string,
|
||||||
|
salt: Buffer
|
||||||
|
): Promise<Buffer> {
|
||||||
|
// Use PBKDF2 with 100,000 iterations for key derivation
|
||||||
|
// Note: react-native-quick-crypto uses uppercase hash names
|
||||||
|
const key = crypto.pbkdf2Sync(
|
||||||
|
deviceSecret,
|
||||||
|
salt,
|
||||||
|
100000,
|
||||||
|
KEY_LENGTH,
|
||||||
|
'SHA-256'
|
||||||
|
);
|
||||||
|
|
||||||
|
return Buffer.from(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt an encrypted push notification payload
|
||||||
|
*/
|
||||||
|
export async function decryptPushPayload(
|
||||||
|
encrypted: EncryptedPushPayload
|
||||||
|
): Promise<DecryptedPayload> {
|
||||||
|
// Validate version
|
||||||
|
if (encrypted.v !== 1) {
|
||||||
|
throw new Error(`Unsupported encryption version: ${encrypted.v}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get device secret
|
||||||
|
const deviceSecret = await getDeviceSecret();
|
||||||
|
|
||||||
|
// Decode Base64 values
|
||||||
|
const iv = Buffer.from(encrypted.iv, 'base64');
|
||||||
|
const salt = Buffer.from(encrypted.salt, 'base64');
|
||||||
|
const ciphertext = Buffer.from(encrypted.ct, 'base64');
|
||||||
|
const authTag = Buffer.from(encrypted.tag, 'base64');
|
||||||
|
|
||||||
|
// Validate lengths
|
||||||
|
if (iv.length !== IV_LENGTH) {
|
||||||
|
throw new Error(`Invalid IV length: ${iv.length}`);
|
||||||
|
}
|
||||||
|
if (salt.length !== SALT_LENGTH) {
|
||||||
|
throw new Error(`Invalid salt length: ${salt.length}`);
|
||||||
|
}
|
||||||
|
if (authTag.length !== AUTH_TAG_LENGTH) {
|
||||||
|
throw new Error(`Invalid auth tag length: ${authTag.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive key using the separate salt from payload
|
||||||
|
const key = await deriveKey(deviceSecret, salt);
|
||||||
|
|
||||||
|
// Create decipher
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) as ReturnType<typeof crypto.createDecipheriv> & {
|
||||||
|
setAuthTag: (tag: Buffer) => void;
|
||||||
|
};
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(ciphertext),
|
||||||
|
decipher.final(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Parse JSON
|
||||||
|
const payload = JSON.parse(decrypted.toString('utf8')) as DecryptedPayload;
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Crypto] Decryption failed:', error);
|
||||||
|
throw new Error('Failed to decrypt push payload');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt data for testing purposes (client-side encryption)
|
||||||
|
* This is primarily used for development/testing.
|
||||||
|
* In production, encryption happens on the server.
|
||||||
|
*/
|
||||||
|
export async function encryptData(
|
||||||
|
data: Record<string, unknown>
|
||||||
|
): Promise<EncryptedPushPayload> {
|
||||||
|
try {
|
||||||
|
const deviceSecret = await getDeviceSecret();
|
||||||
|
|
||||||
|
// Generate random IV and salt separately (NIST: salt should be at least 128 bits)
|
||||||
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
|
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||||
|
|
||||||
|
// Derive key using proper random salt (wrap in Buffer for type compatibility)
|
||||||
|
const key = await deriveKey(deviceSecret, Buffer.from(salt));
|
||||||
|
|
||||||
|
// Create cipher
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv) as ReturnType<typeof crypto.createCipheriv> & {
|
||||||
|
getAuthTag: () => Buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
const plaintext = Buffer.from(JSON.stringify(data), 'utf8');
|
||||||
|
const encrypted = Buffer.concat([
|
||||||
|
cipher.update(plaintext),
|
||||||
|
cipher.final(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get auth tag
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
return {
|
||||||
|
v: 1,
|
||||||
|
iv: Buffer.from(iv).toString('base64'),
|
||||||
|
salt: Buffer.from(salt).toString('base64'),
|
||||||
|
ct: encrypted.toString('base64'),
|
||||||
|
tag: authTag.toString('base64'),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Crypto] Encryption failed:', error);
|
||||||
|
throw new Error('Failed to encrypt data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if encryption is available on this device
|
||||||
|
*/
|
||||||
|
export function isEncryptionAvailable(): boolean {
|
||||||
|
try {
|
||||||
|
// Test if crypto functions are available
|
||||||
|
const testBytes = crypto.randomBytes(16);
|
||||||
|
return testBytes.length === 16;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the device secret (on logout/unpair)
|
||||||
|
*/
|
||||||
|
export async function clearDeviceSecret(): Promise<void> {
|
||||||
|
await SecureStore.deleteItemAsync(DEVICE_SECRET_KEY);
|
||||||
|
console.log('[Crypto] Cleared device secret');
|
||||||
|
}
|
||||||
366
apps/mobile/src/lib/storage.ts
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
/**
|
||||||
|
* Secure storage utilities for mobile app credentials
|
||||||
|
* Supports multiple server connections with independent credentials
|
||||||
|
*
|
||||||
|
* Uses expo-secure-store for all storage (Expo Go compatible)
|
||||||
|
*/
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
|
// Keys for secure storage (per-server, uses serverId suffix)
|
||||||
|
const SECURE_KEYS = {
|
||||||
|
ACCESS_TOKEN: 'tracearr_access_token',
|
||||||
|
REFRESH_TOKEN: 'tracearr_refresh_token',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Keys for general storage (JSON-serializable data, stored in SecureStore)
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
SERVERS: 'tracearr_servers',
|
||||||
|
ACTIVE_SERVER: 'tracearr_active_server',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server connection info stored in SecureStore
|
||||||
|
*/
|
||||||
|
export interface ServerInfo {
|
||||||
|
id: string; // Unique identifier (from pairing response or generated)
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
type: 'plex' | 'jellyfin' | 'emby';
|
||||||
|
addedAt: string; // ISO date string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credentials for a specific server (tokens stored in SecureStore)
|
||||||
|
*/
|
||||||
|
export interface ServerCredentials {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full server data including credentials
|
||||||
|
*/
|
||||||
|
export interface StoredServer extends ServerInfo {
|
||||||
|
credentials: ServerCredentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get per-server secure key
|
||||||
|
function getSecureKey(baseKey: string, serverId: string): string {
|
||||||
|
return `${baseKey}_${serverId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const storage = {
|
||||||
|
// ============================================================================
|
||||||
|
// Server List Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all connected servers
|
||||||
|
*/
|
||||||
|
async getServers(): Promise<ServerInfo[]> {
|
||||||
|
const data = await SecureStore.getItemAsync(STORAGE_KEYS.SERVERS);
|
||||||
|
if (!data) return [];
|
||||||
|
try {
|
||||||
|
return JSON.parse(data) as ServerInfo[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new server to the list
|
||||||
|
*/
|
||||||
|
async addServer(server: ServerInfo, credentials: ServerCredentials): Promise<void> {
|
||||||
|
// Store credentials in SecureStore
|
||||||
|
await Promise.all([
|
||||||
|
SecureStore.setItemAsync(
|
||||||
|
getSecureKey(SECURE_KEYS.ACCESS_TOKEN, server.id),
|
||||||
|
credentials.accessToken
|
||||||
|
),
|
||||||
|
SecureStore.setItemAsync(
|
||||||
|
getSecureKey(SECURE_KEYS.REFRESH_TOKEN, server.id),
|
||||||
|
credentials.refreshToken
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add server to list
|
||||||
|
const servers = await this.getServers();
|
||||||
|
const existingIndex = servers.findIndex((s) => s.id === server.id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
servers[existingIndex] = server;
|
||||||
|
} else {
|
||||||
|
servers.push(server);
|
||||||
|
}
|
||||||
|
await SecureStore.setItemAsync(STORAGE_KEYS.SERVERS, JSON.stringify(servers));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a server and its credentials
|
||||||
|
*/
|
||||||
|
async removeServer(serverId: string): Promise<void> {
|
||||||
|
// Read active server ID BEFORE making any changes to avoid race conditions
|
||||||
|
const activeId = await this.getActiveServerId();
|
||||||
|
const servers = await this.getServers();
|
||||||
|
const filtered = servers.filter((s) => s.id !== serverId);
|
||||||
|
|
||||||
|
// Remove credentials from SecureStore
|
||||||
|
await Promise.all([
|
||||||
|
SecureStore.deleteItemAsync(getSecureKey(SECURE_KEYS.ACCESS_TOKEN, serverId)),
|
||||||
|
SecureStore.deleteItemAsync(getSecureKey(SECURE_KEYS.REFRESH_TOKEN, serverId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Remove from server list
|
||||||
|
await SecureStore.setItemAsync(STORAGE_KEYS.SERVERS, JSON.stringify(filtered));
|
||||||
|
|
||||||
|
// Update active server if the removed one was active
|
||||||
|
if (activeId === serverId) {
|
||||||
|
// Select first remaining server or clear
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
await this.setActiveServerId(filtered[0]!.id);
|
||||||
|
} else {
|
||||||
|
await SecureStore.deleteItemAsync(STORAGE_KEYS.ACTIVE_SERVER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific server by ID
|
||||||
|
*/
|
||||||
|
async getServer(serverId: string): Promise<ServerInfo | null> {
|
||||||
|
const servers = await this.getServers();
|
||||||
|
return servers.find((s) => s.id === serverId) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update server info (e.g., name changed)
|
||||||
|
*/
|
||||||
|
async updateServer(serverId: string, updates: Partial<Omit<ServerInfo, 'id'>>): Promise<void> {
|
||||||
|
const servers = await this.getServers();
|
||||||
|
const index = servers.findIndex((s) => s.id === serverId);
|
||||||
|
if (index >= 0) {
|
||||||
|
servers[index] = { ...servers[index]!, ...updates };
|
||||||
|
await SecureStore.setItemAsync(STORAGE_KEYS.SERVERS, JSON.stringify(servers));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Active Server Selection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently active server ID
|
||||||
|
*/
|
||||||
|
async getActiveServerId(): Promise<string | null> {
|
||||||
|
return SecureStore.getItemAsync(STORAGE_KEYS.ACTIVE_SERVER);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the active server
|
||||||
|
*/
|
||||||
|
async setActiveServerId(serverId: string): Promise<void> {
|
||||||
|
await SecureStore.setItemAsync(STORAGE_KEYS.ACTIVE_SERVER, serverId);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the active server info
|
||||||
|
*/
|
||||||
|
async getActiveServer(): Promise<ServerInfo | null> {
|
||||||
|
const activeId = await this.getActiveServerId();
|
||||||
|
if (!activeId) return null;
|
||||||
|
return this.getServer(activeId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Credentials Management (per-server)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get credentials for a specific server
|
||||||
|
*/
|
||||||
|
async getServerCredentials(serverId: string): Promise<ServerCredentials | null> {
|
||||||
|
const [accessToken, refreshToken] = await Promise.all([
|
||||||
|
SecureStore.getItemAsync(getSecureKey(SECURE_KEYS.ACCESS_TOKEN, serverId)),
|
||||||
|
SecureStore.getItemAsync(getSecureKey(SECURE_KEYS.REFRESH_TOKEN, serverId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!accessToken || !refreshToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accessToken, refreshToken };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update tokens for a specific server (after refresh)
|
||||||
|
*/
|
||||||
|
async updateServerTokens(
|
||||||
|
serverId: string,
|
||||||
|
accessToken: string,
|
||||||
|
refreshToken: string
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
SecureStore.setItemAsync(getSecureKey(SECURE_KEYS.ACCESS_TOKEN, serverId), accessToken),
|
||||||
|
SecureStore.setItemAsync(getSecureKey(SECURE_KEYS.REFRESH_TOKEN, serverId), refreshToken),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get access token for active server
|
||||||
|
*/
|
||||||
|
async getAccessToken(): Promise<string | null> {
|
||||||
|
const activeId = await this.getActiveServerId();
|
||||||
|
if (!activeId) return null;
|
||||||
|
return SecureStore.getItemAsync(getSecureKey(SECURE_KEYS.ACCESS_TOKEN, activeId));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get refresh token for active server
|
||||||
|
*/
|
||||||
|
async getRefreshToken(): Promise<string | null> {
|
||||||
|
const activeId = await this.getActiveServerId();
|
||||||
|
if (!activeId) return null;
|
||||||
|
return SecureStore.getItemAsync(getSecureKey(SECURE_KEYS.REFRESH_TOKEN, activeId));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get server URL for active server
|
||||||
|
*/
|
||||||
|
async getServerUrl(): Promise<string | null> {
|
||||||
|
const server = await this.getActiveServer();
|
||||||
|
return server?.url ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update tokens for active server
|
||||||
|
*/
|
||||||
|
async updateTokens(accessToken: string, refreshToken: string): Promise<void> {
|
||||||
|
const activeId = await this.getActiveServerId();
|
||||||
|
if (!activeId) throw new Error('No active server');
|
||||||
|
await this.updateServerTokens(activeId, accessToken, refreshToken);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Migration & Compatibility
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if using legacy single-server storage and migrate if needed
|
||||||
|
*/
|
||||||
|
async migrateFromLegacy(): Promise<boolean> {
|
||||||
|
// Check for legacy keys
|
||||||
|
const legacyUrl = await SecureStore.getItemAsync('tracearr_server_url');
|
||||||
|
const legacyAccess = await SecureStore.getItemAsync('tracearr_access_token');
|
||||||
|
const legacyRefresh = await SecureStore.getItemAsync('tracearr_refresh_token');
|
||||||
|
const legacyName = await SecureStore.getItemAsync('tracearr_server_name');
|
||||||
|
|
||||||
|
if (legacyUrl && legacyAccess && legacyRefresh) {
|
||||||
|
// Generate a server ID from the URL
|
||||||
|
const serverId = Buffer.from(legacyUrl).toString('base64').slice(0, 16);
|
||||||
|
|
||||||
|
const serverInfo: ServerInfo = {
|
||||||
|
id: serverId,
|
||||||
|
url: legacyUrl,
|
||||||
|
name: legacyName || 'Tracearr',
|
||||||
|
type: 'plex', // Assume plex for legacy, will update on next sync
|
||||||
|
addedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add server with credentials
|
||||||
|
await this.addServer(serverInfo, {
|
||||||
|
accessToken: legacyAccess,
|
||||||
|
refreshToken: legacyRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set as active
|
||||||
|
await this.setActiveServerId(serverId);
|
||||||
|
|
||||||
|
// Clean up legacy keys
|
||||||
|
await Promise.all([
|
||||||
|
SecureStore.deleteItemAsync('tracearr_server_url'),
|
||||||
|
SecureStore.deleteItemAsync('tracearr_access_token'),
|
||||||
|
SecureStore.deleteItemAsync('tracearr_refresh_token'),
|
||||||
|
SecureStore.deleteItemAsync('tracearr_server_name'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Legacy Compatibility (for existing code during transition)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use getServers() and getServerCredentials() instead
|
||||||
|
* Get stored credentials for active server (legacy compatibility)
|
||||||
|
*/
|
||||||
|
async getCredentials(): Promise<{
|
||||||
|
serverUrl: string;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
serverName: string;
|
||||||
|
} | null> {
|
||||||
|
const server = await this.getActiveServer();
|
||||||
|
if (!server) return null;
|
||||||
|
|
||||||
|
const credentials = await this.getServerCredentials(server.id);
|
||||||
|
if (!credentials) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverUrl: server.url,
|
||||||
|
accessToken: credentials.accessToken,
|
||||||
|
refreshToken: credentials.refreshToken,
|
||||||
|
serverName: server.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use addServer() instead
|
||||||
|
* Store credentials (legacy compatibility - adds/updates single server)
|
||||||
|
*/
|
||||||
|
async storeCredentials(credentials: {
|
||||||
|
serverUrl: string;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
serverName: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
// Generate ID from URL for consistency
|
||||||
|
const serverId = Buffer.from(credentials.serverUrl).toString('base64').slice(0, 16);
|
||||||
|
|
||||||
|
const serverInfo: ServerInfo = {
|
||||||
|
id: serverId,
|
||||||
|
url: credentials.serverUrl,
|
||||||
|
name: credentials.serverName,
|
||||||
|
type: 'plex', // Will be updated on server sync
|
||||||
|
addedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.addServer(serverInfo, {
|
||||||
|
accessToken: credentials.accessToken,
|
||||||
|
refreshToken: credentials.refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.setActiveServerId(serverId);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use removeServer() for specific server
|
||||||
|
* Clear all credentials (legacy compatibility - removes active server)
|
||||||
|
*/
|
||||||
|
async clearCredentials(): Promise<void> {
|
||||||
|
const activeId = await this.getActiveServerId();
|
||||||
|
if (activeId) {
|
||||||
|
await this.removeServer(activeId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is authenticated (has at least one server)
|
||||||
|
*/
|
||||||
|
async isAuthenticated(): Promise<boolean> {
|
||||||
|
const servers = await this.getServers();
|
||||||
|
return servers.length > 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
167
apps/mobile/src/lib/theme.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Design system tokens matching Tracearr web app
|
||||||
|
* These values match the web's dark mode theme exactly
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const colors = {
|
||||||
|
// Brand colors (from Tracearr web)
|
||||||
|
cyan: {
|
||||||
|
core: '#18D1E7',
|
||||||
|
deep: '#0EAFC8',
|
||||||
|
dark: '#0A7C96',
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
core: '#0B1A2E',
|
||||||
|
steel: '#162840',
|
||||||
|
soft: '#1E3A5C',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Background colors - matching web dark mode
|
||||||
|
background: {
|
||||||
|
dark: '#050A12',
|
||||||
|
light: '#F9FAFB',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
dark: '#0B1A2E',
|
||||||
|
light: '#FFFFFF',
|
||||||
|
},
|
||||||
|
surface: {
|
||||||
|
dark: '#0F2338',
|
||||||
|
light: '#F3F4F6',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Accent colors
|
||||||
|
orange: {
|
||||||
|
core: '#F97316',
|
||||||
|
},
|
||||||
|
purple: '#8B5CF6',
|
||||||
|
|
||||||
|
// Status colors
|
||||||
|
success: '#22C55E',
|
||||||
|
warning: '#F59E0B',
|
||||||
|
error: '#EF4444',
|
||||||
|
danger: '#EF4444',
|
||||||
|
info: '#3B82F6',
|
||||||
|
|
||||||
|
// Switch/toggle colors - matching web dark mode border
|
||||||
|
switch: {
|
||||||
|
trackOff: '#162840',
|
||||||
|
trackOn: '#0EAFC8',
|
||||||
|
thumbOn: '#18D1E7',
|
||||||
|
thumbOff: '#64748B',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Text colors - matching web dark mode (CSS: --color-*)
|
||||||
|
text: {
|
||||||
|
// --color-foreground / --color-card-foreground
|
||||||
|
primary: {
|
||||||
|
dark: '#FFFFFF',
|
||||||
|
light: '#0B1A2E',
|
||||||
|
},
|
||||||
|
// --color-muted-foreground (used for secondary/muted text)
|
||||||
|
secondary: {
|
||||||
|
dark: '#94A3B8',
|
||||||
|
light: '#64748B',
|
||||||
|
},
|
||||||
|
// Alias for secondary - use this for muted text in inline styles
|
||||||
|
muted: {
|
||||||
|
dark: '#94A3B8',
|
||||||
|
light: '#64748B',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Icon colors - matching web dark mode (CSS: --color-icon-*)
|
||||||
|
icon: {
|
||||||
|
default: '#8CA3B8',
|
||||||
|
active: '#18D1E7',
|
||||||
|
danger: '#FF4C4C',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Border colors - matching web dark mode (blue-steel)
|
||||||
|
border: {
|
||||||
|
dark: '#162840',
|
||||||
|
light: '#E5E7EB',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Chart colors
|
||||||
|
chart: ['#18D1E7', '#0EAFC8', '#1E3A5C', '#F59E0B', '#EF4444', '#22C55E'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const spacing = {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 16,
|
||||||
|
lg: 24,
|
||||||
|
xl: 32,
|
||||||
|
xxl: 48,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const borderRadius = {
|
||||||
|
sm: 4,
|
||||||
|
md: 8,
|
||||||
|
lg: 12,
|
||||||
|
xl: 16,
|
||||||
|
full: 9999,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const typography = {
|
||||||
|
fontFamily: {
|
||||||
|
regular: 'System',
|
||||||
|
medium: 'System',
|
||||||
|
bold: 'System',
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
xs: 12,
|
||||||
|
sm: 14,
|
||||||
|
base: 16,
|
||||||
|
lg: 18,
|
||||||
|
xl: 20,
|
||||||
|
'2xl': 24,
|
||||||
|
'3xl': 30,
|
||||||
|
'4xl': 36,
|
||||||
|
},
|
||||||
|
lineHeight: {
|
||||||
|
tight: 1.25,
|
||||||
|
normal: 1.5,
|
||||||
|
relaxed: 1.75,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const shadows = {
|
||||||
|
sm: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 15,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Helper function to get theme-aware colors
|
||||||
|
export function getThemeColor(
|
||||||
|
colorKey: 'background' | 'card' | 'surface' | 'border',
|
||||||
|
isDark: boolean
|
||||||
|
): string {
|
||||||
|
return colors[colorKey][isDark ? 'dark' : 'light'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextColor(
|
||||||
|
variant: 'primary' | 'secondary' | 'muted',
|
||||||
|
isDark: boolean
|
||||||
|
): string {
|
||||||
|
return colors.text[variant][isDark ? 'dark' : 'light'];
|
||||||
|
}
|
||||||
6
apps/mobile/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
153
apps/mobile/src/providers/MediaServerProvider.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Media Server selection provider
|
||||||
|
* Fetches available servers from Tracearr API and manages selection
|
||||||
|
* Similar to web's useServer hook
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useEffect,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import type { Server } from '@tracearr/shared';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { useAuthStore } from '../lib/authStore';
|
||||||
|
|
||||||
|
const SELECTED_SERVER_KEY = 'tracearr_selected_media_server';
|
||||||
|
|
||||||
|
interface MediaServerContextValue {
|
||||||
|
servers: Server[];
|
||||||
|
selectedServer: Server | null;
|
||||||
|
selectedServerId: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
selectServer: (serverId: string | null) => void;
|
||||||
|
refetch: () => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaServerContext = createContext<MediaServerContextValue | null>(null);
|
||||||
|
|
||||||
|
export function MediaServerProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { isAuthenticated, activeServerId: tracearrBackendId } = useAuthStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
// Load saved selection on mount
|
||||||
|
useEffect(() => {
|
||||||
|
void SecureStore.getItemAsync(SELECTED_SERVER_KEY).then((saved) => {
|
||||||
|
if (saved) {
|
||||||
|
setSelectedServerId(saved);
|
||||||
|
}
|
||||||
|
setInitialized(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch available servers from API
|
||||||
|
const {
|
||||||
|
data: servers = [],
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['media-servers', tracearrBackendId],
|
||||||
|
queryFn: () => api.servers.list(),
|
||||||
|
enabled: isAuthenticated && !!tracearrBackendId,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate selection when servers load
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialized || isLoading) return;
|
||||||
|
|
||||||
|
if (servers.length === 0) {
|
||||||
|
if (selectedServerId) {
|
||||||
|
setSelectedServerId(null);
|
||||||
|
void SecureStore.deleteItemAsync(SELECTED_SERVER_KEY);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If selection is invalid (server no longer exists), select first
|
||||||
|
if (selectedServerId && !servers.some((s) => s.id === selectedServerId)) {
|
||||||
|
const firstServer = servers[0];
|
||||||
|
if (firstServer) {
|
||||||
|
setSelectedServerId(firstServer.id);
|
||||||
|
void SecureStore.setItemAsync(SELECTED_SERVER_KEY, firstServer.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no selection but servers exist, select first
|
||||||
|
if (!selectedServerId && servers.length > 0) {
|
||||||
|
const firstServer = servers[0];
|
||||||
|
if (firstServer) {
|
||||||
|
setSelectedServerId(firstServer.id);
|
||||||
|
void SecureStore.setItemAsync(SELECTED_SERVER_KEY, firstServer.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [servers, selectedServerId, initialized, isLoading]);
|
||||||
|
|
||||||
|
// Clear selection on logout
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setSelectedServerId(null);
|
||||||
|
void SecureStore.deleteItemAsync(SELECTED_SERVER_KEY);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
const selectServer = useCallback(
|
||||||
|
(serverId: string | null) => {
|
||||||
|
setSelectedServerId(serverId);
|
||||||
|
if (serverId) {
|
||||||
|
void SecureStore.setItemAsync(SELECTED_SERVER_KEY, serverId);
|
||||||
|
} else {
|
||||||
|
void SecureStore.deleteItemAsync(SELECTED_SERVER_KEY);
|
||||||
|
}
|
||||||
|
// Invalidate all server-dependent queries
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => {
|
||||||
|
const key = query.queryKey[0];
|
||||||
|
return key !== 'media-servers' && key !== 'servers';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedServer = useMemo(() => {
|
||||||
|
if (!selectedServerId) return null;
|
||||||
|
return servers.find((s) => s.id === selectedServerId) ?? null;
|
||||||
|
}, [servers, selectedServerId]);
|
||||||
|
|
||||||
|
const value = useMemo<MediaServerContextValue>(
|
||||||
|
() => ({
|
||||||
|
servers,
|
||||||
|
selectedServer,
|
||||||
|
selectedServerId,
|
||||||
|
isLoading,
|
||||||
|
selectServer,
|
||||||
|
refetch,
|
||||||
|
}),
|
||||||
|
[servers, selectedServer, selectedServerId, isLoading, selectServer, refetch]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MediaServerContext.Provider value={value}>{children}</MediaServerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMediaServer(): MediaServerContextValue {
|
||||||
|
const context = useContext(MediaServerContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useMediaServer must be used within a MediaServerProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSelectedServerId(): string | null {
|
||||||
|
const { selectedServerId } = useMediaServer();
|
||||||
|
return selectedServerId;
|
||||||
|
}
|
||||||
32
apps/mobile/src/providers/QueryProvider.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* React Query provider for data fetching
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60, // 1 minute
|
||||||
|
retry: 2,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface QueryProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QueryProvider({ children }: QueryProviderProps) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { queryClient };
|
||||||
160
apps/mobile/src/providers/SocketProvider.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Socket.io provider for real-time updates
|
||||||
|
* Connects to Tracearr backend and invalidates queries on events
|
||||||
|
*/
|
||||||
|
import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
import type { Socket } from 'socket.io-client';
|
||||||
|
import { AppState } from 'react-native';
|
||||||
|
import type { AppStateStatus } from 'react-native';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { storage } from '../lib/storage';
|
||||||
|
import { useAuthStore } from '../lib/authStore';
|
||||||
|
import type {
|
||||||
|
ServerToClientEvents,
|
||||||
|
ClientToServerEvents,
|
||||||
|
ActiveSession,
|
||||||
|
ViolationWithDetails,
|
||||||
|
DashboardStats,
|
||||||
|
} from '@tracearr/shared';
|
||||||
|
|
||||||
|
interface SocketContextValue {
|
||||||
|
socket: Socket<ServerToClientEvents, ClientToServerEvents> | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SocketContext = createContext<SocketContextValue>({
|
||||||
|
socket: null,
|
||||||
|
isConnected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useSocket() {
|
||||||
|
const context = useContext(SocketContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSocket must be used within a SocketProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SocketProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, activeServerId, serverUrl } = useAuthStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const socketRef = useRef<Socket<ServerToClientEvents, ClientToServerEvents> | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
// Track which Tracearr backend we're connected to
|
||||||
|
const connectedServerIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const connectSocket = useCallback(async () => {
|
||||||
|
if (!isAuthenticated || !serverUrl || !activeServerId) return;
|
||||||
|
|
||||||
|
// If already connected to this backend, skip
|
||||||
|
if (connectedServerIdRef.current === activeServerId && socketRef.current?.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect existing socket if connected to different backend
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = await storage.getServerCredentials(activeServerId);
|
||||||
|
if (!credentials) return;
|
||||||
|
|
||||||
|
connectedServerIdRef.current = activeServerId;
|
||||||
|
|
||||||
|
const newSocket: Socket<ServerToClientEvents, ClientToServerEvents> = io(serverUrl, {
|
||||||
|
auth: { token: credentials.accessToken },
|
||||||
|
transports: ['websocket'],
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionDelayMax: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('connect', () => {
|
||||||
|
console.log('Socket connected');
|
||||||
|
setIsConnected(true);
|
||||||
|
// Subscribe to session updates
|
||||||
|
newSocket.emit('subscribe:sessions');
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('disconnect', (reason) => {
|
||||||
|
console.log('Socket disconnected:', reason);
|
||||||
|
setIsConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('connect_error', (error) => {
|
||||||
|
console.error('Socket connection error:', error.message);
|
||||||
|
setIsConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle real-time events
|
||||||
|
// Use partial query keys to invalidate ALL cached data regardless of selected media server
|
||||||
|
// This matches the web app pattern where socket events invalidate all server-filtered caches
|
||||||
|
newSocket.on('session:started', (_session: ActiveSession) => {
|
||||||
|
// Invalidate all active sessions caches (any server filter)
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('session:stopped', (_sessionId: string) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('session:updated', (_session: ActiveSession) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('violation:new', (_violation: ViolationWithDetails) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['violations'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('stats:updated', (_stats: DashboardStats) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
socketRef.current = newSocket;
|
||||||
|
}, [isAuthenticated, serverUrl, activeServerId, queryClient]);
|
||||||
|
|
||||||
|
// Connect/disconnect based on auth state
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && serverUrl && activeServerId) {
|
||||||
|
void connectSocket();
|
||||||
|
} else if (socketRef.current) {
|
||||||
|
socketRef.current.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
connectedServerIdRef.current = null;
|
||||||
|
setIsConnected(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
connectedServerIdRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isAuthenticated, serverUrl, activeServerId, connectSocket]);
|
||||||
|
|
||||||
|
// Handle app state changes (background/foreground)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleAppStateChange = (nextState: AppStateStatus) => {
|
||||||
|
if (nextState === 'active' && isAuthenticated && !isConnected) {
|
||||||
|
// Reconnect when app comes to foreground
|
||||||
|
void connectSocket();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, [isAuthenticated, isConnected, connectSocket]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SocketContext.Provider value={{ socket: socketRef.current, isConnected }}>
|
||||||
|
{children}
|
||||||
|
</SocketContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/mobile/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
],
|
||||||
|
"@tracearr/shared": [
|
||||||
|
"../../packages/shared/src"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"types": [
|
||||||
|
"expo-router/types",
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts",
|
||||||
|
"nativewind-env.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
16
apps/server/drizzle.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
throw new Error('DATABASE_URL environment variable is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/db/schema.ts',
|
||||||
|
out: './src/db/migrations',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL,
|
||||||
|
},
|
||||||
|
verbose: true,
|
||||||
|
strict: true,
|
||||||
|
});
|
||||||
73
apps/server/package.json
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"name": "@tracearr/server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch --env-file=../../.env src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"lint:fix": "eslint src/ --fix",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:all": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:unit": "vitest run --config vitest.unit.config.ts",
|
||||||
|
"test:services": "vitest run --config vitest.services.config.ts",
|
||||||
|
"test:routes": "vitest run --config vitest.routes.config.ts",
|
||||||
|
"test:security": "vitest run --config vitest.security.config.ts",
|
||||||
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:unit:coverage": "vitest run --config vitest.unit.config.ts --coverage",
|
||||||
|
"test:services:coverage": "vitest run --config vitest.services.config.ts --coverage",
|
||||||
|
"test:routes:coverage": "vitest run --config vitest.routes.config.ts --coverage",
|
||||||
|
"clean": "rm -rf dist .turbo coverage",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:studio": "drizzle-kit studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.0.0",
|
||||||
|
"@fastify/cors": "^11.0.0",
|
||||||
|
"@fastify/helmet": "^13.0.0",
|
||||||
|
"@fastify/jwt": "^10.0.0",
|
||||||
|
"@fastify/rate-limit": "^10.0.0",
|
||||||
|
"@fastify/sensible": "^6.0.0",
|
||||||
|
"@fastify/static": "^8.0.0",
|
||||||
|
"@fastify/swagger": "^9.0.0",
|
||||||
|
"@fastify/swagger-ui": "^5.0.0",
|
||||||
|
"@fastify/websocket": "^11.0.0",
|
||||||
|
"@tracearr/shared": "workspace:*",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"bullmq": "^5.65.1",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"drizzle-orm": "^0.44.0",
|
||||||
|
"eventsource": "^4.1.0",
|
||||||
|
"expo-server-sdk": "^4.0.0",
|
||||||
|
"fastify": "^5.0.0",
|
||||||
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"ioredis": "^5.4.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"maxmind": "^4.3.29",
|
||||||
|
"pg": "^8.13.0",
|
||||||
|
"sharp": "^0.34.0",
|
||||||
|
"socket.io": "^4.8.0",
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tracearr/test-utils": "workspace:*",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/eventsource": "^3.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
|
"@vitest/coverage-v8": "^4.0.0",
|
||||||
|
"drizzle-kit": "^0.31.0",
|
||||||
|
"pino-pretty": "^13.0.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apps/server/src/db/client.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Database client and connection pool
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
|
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||||
|
import pg from 'pg';
|
||||||
|
import * as schema from './schema.js';
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
throw new Error('DATABASE_URL environment variable is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
max: 20, // Maximum connections
|
||||||
|
idleTimeoutMillis: 20000, // Close idle connections after 20s
|
||||||
|
connectionTimeoutMillis: 10000, // Connection timeout (increased for complex queries)
|
||||||
|
maxUses: 7500, // Max queries per connection before refresh (prevents memory leaks)
|
||||||
|
allowExitOnIdle: false, // Keep pool alive during idle periods
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log pool errors for debugging
|
||||||
|
pool.on('error', (err) => {
|
||||||
|
console.error('[DB Pool Error]', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const db = drizzle(pool, { schema });
|
||||||
|
|
||||||
|
export async function closeDatabase(): Promise<void> {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkDatabaseConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const client = await pool.connect();
|
||||||
|
await client.query('SELECT 1');
|
||||||
|
client.release();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Database connection check failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runMigrations(migrationsFolder: string): Promise<void> {
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
}
|
||||||
154
apps/server/src/db/migrations/0000_lying_dorian_gray.sql
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
CREATE TABLE "rules" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" varchar(100) NOT NULL,
|
||||||
|
"type" varchar(50) NOT NULL,
|
||||||
|
"params" jsonb NOT NULL,
|
||||||
|
"server_user_id" uuid,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "server_users" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"server_id" uuid NOT NULL,
|
||||||
|
"external_id" varchar(255) NOT NULL,
|
||||||
|
"username" varchar(255) NOT NULL,
|
||||||
|
"email" varchar(255),
|
||||||
|
"thumb_url" text,
|
||||||
|
"is_server_admin" boolean DEFAULT false NOT NULL,
|
||||||
|
"trust_score" integer DEFAULT 100 NOT NULL,
|
||||||
|
"session_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "servers" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" varchar(100) NOT NULL,
|
||||||
|
"type" varchar(20) NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "sessions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"server_id" uuid NOT NULL,
|
||||||
|
"server_user_id" uuid NOT NULL,
|
||||||
|
"session_key" varchar(255) NOT NULL,
|
||||||
|
"state" varchar(20) NOT NULL,
|
||||||
|
"media_type" varchar(20) NOT NULL,
|
||||||
|
"media_title" text NOT NULL,
|
||||||
|
"grandparent_title" varchar(500),
|
||||||
|
"season_number" integer,
|
||||||
|
"episode_number" integer,
|
||||||
|
"year" integer,
|
||||||
|
"thumb_path" varchar(500),
|
||||||
|
"rating_key" varchar(255),
|
||||||
|
"external_session_id" varchar(255),
|
||||||
|
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"stopped_at" timestamp with time zone,
|
||||||
|
"duration_ms" integer,
|
||||||
|
"total_duration_ms" integer,
|
||||||
|
"progress_ms" integer,
|
||||||
|
"last_paused_at" timestamp with time zone,
|
||||||
|
"paused_duration_ms" integer DEFAULT 0 NOT NULL,
|
||||||
|
"reference_id" uuid,
|
||||||
|
"watched" boolean DEFAULT false NOT NULL,
|
||||||
|
"ip_address" varchar(45) NOT NULL,
|
||||||
|
"geo_city" varchar(255),
|
||||||
|
"geo_region" varchar(255),
|
||||||
|
"geo_country" varchar(100),
|
||||||
|
"geo_lat" real,
|
||||||
|
"geo_lon" real,
|
||||||
|
"player_name" varchar(255),
|
||||||
|
"device_id" varchar(255),
|
||||||
|
"product" varchar(255),
|
||||||
|
"device" varchar(255),
|
||||||
|
"platform" varchar(100),
|
||||||
|
"quality" varchar(100),
|
||||||
|
"is_transcode" boolean DEFAULT false NOT NULL,
|
||||||
|
"bitrate" integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "settings" (
|
||||||
|
"id" integer PRIMARY KEY DEFAULT 1 NOT NULL,
|
||||||
|
"allow_guest_access" boolean DEFAULT false NOT NULL,
|
||||||
|
"discord_webhook_url" text,
|
||||||
|
"custom_webhook_url" text,
|
||||||
|
"notify_on_violation" boolean DEFAULT true NOT NULL,
|
||||||
|
"notify_on_session_start" boolean DEFAULT false NOT NULL,
|
||||||
|
"notify_on_session_stop" boolean DEFAULT false NOT NULL,
|
||||||
|
"notify_on_server_down" boolean DEFAULT true NOT NULL,
|
||||||
|
"poller_enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"poller_interval_ms" integer DEFAULT 15000 NOT NULL,
|
||||||
|
"tautulli_url" text,
|
||||||
|
"tautulli_api_key" text,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"username" varchar(100) NOT NULL,
|
||||||
|
"name" varchar(255),
|
||||||
|
"thumbnail" text,
|
||||||
|
"email" varchar(255),
|
||||||
|
"password_hash" text,
|
||||||
|
"plex_account_id" varchar(255),
|
||||||
|
"role" varchar(20) DEFAULT 'member' NOT NULL,
|
||||||
|
"aggregate_trust_score" integer DEFAULT 100 NOT NULL,
|
||||||
|
"total_violations" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "violations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"rule_id" uuid NOT NULL,
|
||||||
|
"server_user_id" uuid NOT NULL,
|
||||||
|
"session_id" uuid NOT NULL,
|
||||||
|
"severity" varchar(20) NOT NULL,
|
||||||
|
"data" jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"acknowledged_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "rules" ADD CONSTRAINT "rules_server_user_id_server_users_id_fk" FOREIGN KEY ("server_user_id") REFERENCES "public"."server_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "server_users" ADD CONSTRAINT "server_users_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "server_users" ADD CONSTRAINT "server_users_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_server_user_id_server_users_id_fk" FOREIGN KEY ("server_user_id") REFERENCES "public"."server_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "violations" ADD CONSTRAINT "violations_rule_id_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "public"."rules"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "violations" ADD CONSTRAINT "violations_server_user_id_server_users_id_fk" FOREIGN KEY ("server_user_id") REFERENCES "public"."server_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "violations" ADD CONSTRAINT "violations_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "rules_active_idx" ON "rules" USING btree ("is_active");--> statement-breakpoint
|
||||||
|
CREATE INDEX "rules_server_user_id_idx" ON "rules" USING btree ("server_user_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "server_users_user_server_unique" ON "server_users" USING btree ("user_id","server_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "server_users_server_external_unique" ON "server_users" USING btree ("server_id","external_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "server_users_user_idx" ON "server_users" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "server_users_server_idx" ON "server_users" USING btree ("server_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "server_users_username_idx" ON "server_users" USING btree ("username");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_server_user_time_idx" ON "sessions" USING btree ("server_user_id","started_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_server_time_idx" ON "sessions" USING btree ("server_id","started_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_state_idx" ON "sessions" USING btree ("state");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_external_session_idx" ON "sessions" USING btree ("server_id","external_session_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_device_idx" ON "sessions" USING btree ("server_user_id","device_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_reference_idx" ON "sessions" USING btree ("reference_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_server_user_rating_idx" ON "sessions" USING btree ("server_user_id","rating_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_geo_idx" ON "sessions" USING btree ("geo_lat","geo_lon");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_geo_time_idx" ON "sessions" USING btree ("started_at","geo_lat","geo_lon");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_media_type_idx" ON "sessions" USING btree ("media_type");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_transcode_idx" ON "sessions" USING btree ("is_transcode");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_platform_idx" ON "sessions" USING btree ("platform");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_top_movies_idx" ON "sessions" USING btree ("media_type","media_title","year");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_top_shows_idx" ON "sessions" USING btree ("media_type","grandparent_title");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "users_username_unique" ON "users" USING btree ("username");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "users_email_unique" ON "users" USING btree ("email");--> statement-breakpoint
|
||||||
|
CREATE INDEX "users_plex_account_id_idx" ON "users" USING btree ("plex_account_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "users_role_idx" ON "users" USING btree ("role");--> statement-breakpoint
|
||||||
|
CREATE INDEX "violations_server_user_id_idx" ON "violations" USING btree ("server_user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "violations_rule_id_idx" ON "violations" USING btree ("rule_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "violations_created_at_idx" ON "violations" USING btree ("created_at");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX "users_username_unique";--> statement-breakpoint
|
||||||
|
CREATE INDEX "users_username_idx" ON "users" USING btree ("username");
|
||||||
26
apps/server/src/db/migrations/0002_rainy_bishop.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
CREATE TABLE "mobile_sessions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"refresh_token_hash" varchar(64) NOT NULL,
|
||||||
|
"device_name" varchar(100) NOT NULL,
|
||||||
|
"device_id" varchar(100) NOT NULL,
|
||||||
|
"platform" varchar(20) NOT NULL,
|
||||||
|
"expo_push_token" varchar(255),
|
||||||
|
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "mobile_sessions_refresh_token_hash_unique" UNIQUE("refresh_token_hash")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "mobile_tokens" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"token_hash" varchar(64) NOT NULL,
|
||||||
|
"is_enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"rotated_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "mobile_tokens_token_hash_unique" UNIQUE("token_hash")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "settings" ADD COLUMN "external_url" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "settings" ADD COLUMN "base_path" varchar(100) DEFAULT '' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "settings" ADD COLUMN "trust_proxy" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
CREATE INDEX "mobile_sessions_device_id_idx" ON "mobile_sessions" USING btree ("device_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "mobile_sessions_refresh_token_idx" ON "mobile_sessions" USING btree ("refresh_token_hash");
|
||||||
28
apps/server/src/db/migrations/0003_black_maginty.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE "notification_preferences" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"mobile_session_id" uuid NOT NULL,
|
||||||
|
"push_enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"on_violation_detected" boolean DEFAULT true NOT NULL,
|
||||||
|
"on_stream_started" boolean DEFAULT false NOT NULL,
|
||||||
|
"on_stream_stopped" boolean DEFAULT false NOT NULL,
|
||||||
|
"on_concurrent_streams" boolean DEFAULT true NOT NULL,
|
||||||
|
"on_new_device" boolean DEFAULT true NOT NULL,
|
||||||
|
"on_trust_score_changed" boolean DEFAULT false NOT NULL,
|
||||||
|
"on_server_down" boolean DEFAULT true NOT NULL,
|
||||||
|
"on_server_up" boolean DEFAULT true NOT NULL,
|
||||||
|
"violation_min_severity" integer DEFAULT 1 NOT NULL,
|
||||||
|
"violation_rule_types" text[] DEFAULT '{}',
|
||||||
|
"max_per_minute" integer DEFAULT 10 NOT NULL,
|
||||||
|
"max_per_hour" integer DEFAULT 60 NOT NULL,
|
||||||
|
"quiet_hours_enabled" boolean DEFAULT false NOT NULL,
|
||||||
|
"quiet_hours_start" varchar(5),
|
||||||
|
"quiet_hours_end" varchar(5),
|
||||||
|
"quiet_hours_timezone" varchar(50) DEFAULT 'UTC',
|
||||||
|
"quiet_hours_override_critical" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "notification_preferences_mobile_session_id_unique" UNIQUE("mobile_session_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_mobile_session_id_mobile_sessions_id_fk" FOREIGN KEY ("mobile_session_id") REFERENCES "public"."mobile_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "notification_prefs_mobile_session_idx" ON "notification_preferences" USING btree ("mobile_session_id");
|
||||||
25
apps/server/src/db/migrations/0004_bent_unus.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE "notification_channel_routing" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"event_type" varchar(50) NOT NULL,
|
||||||
|
"discord_enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"webhook_enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"push_enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "notification_channel_routing_event_type_unique" UNIQUE("event_type")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "notification_channel_routing_event_type_idx" ON "notification_channel_routing" USING btree ("event_type");
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Seed default routing configuration for all event types
|
||||||
|
INSERT INTO "notification_channel_routing" ("event_type", "discord_enabled", "webhook_enabled", "push_enabled")
|
||||||
|
VALUES
|
||||||
|
('violation_detected', true, true, true),
|
||||||
|
('stream_started', false, false, false),
|
||||||
|
('stream_stopped', false, false, false),
|
||||||
|
('concurrent_streams', true, true, true),
|
||||||
|
('new_device', true, true, true),
|
||||||
|
('trust_score_changed', false, false, false),
|
||||||
|
('server_down', true, true, true),
|
||||||
|
('server_up', true, true, true)
|
||||||
|
ON CONFLICT ("event_type") DO NOTHING;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "mobile_sessions" ADD COLUMN "device_secret" varchar(64);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
CREATE INDEX "mobile_sessions_expo_push_token_idx" ON "mobile_sessions" USING btree ("expo_push_token");--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification_preferences" ADD CONSTRAINT "quiet_hours_start_format" CHECK ("notification_preferences"."quiet_hours_start" IS NULL OR "notification_preferences"."quiet_hours_start" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$');--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification_preferences" ADD CONSTRAINT "quiet_hours_end_format" CHECK ("notification_preferences"."quiet_hours_end" IS NULL OR "notification_preferences"."quiet_hours_end" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$');
|
||||||