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]$');
|
||||