.env example
This commit is contained in:
parent
a7978de5a0
commit
cd19f8c0b9
251
.env.example
Normal file
251
.env.example
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# Changemaker Lite v2 — Environment Variables
|
||||||
|
# Copy this file to .env and fill in the values
|
||||||
|
# Generate secrets with: openssl rand -hex 32
|
||||||
|
# ==============================================================================
|
||||||
|
#
|
||||||
|
# SECURITY WARNING:
|
||||||
|
# - All passwords marked REQUIRED_STRONG_PASSWORD_CHANGE_THIS MUST be changed
|
||||||
|
# - Use strong, unique passwords (20+ characters recommended)
|
||||||
|
# - Generate secrets with: openssl rand -hex 32
|
||||||
|
# - NEVER commit .env to version control
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# --- General ---
|
||||||
|
NODE_ENV=development
|
||||||
|
# Root domain serves MkDocs documentation site only
|
||||||
|
# All application routes (admin + public) accessible at app.${DOMAIN}
|
||||||
|
DOMAIN=cmlite.org
|
||||||
|
USER_ID=1000
|
||||||
|
GROUP_ID=1000
|
||||||
|
DOCKER_GROUP_ID=984
|
||||||
|
|
||||||
|
# --- V2 PostgreSQL ---
|
||||||
|
V2_POSTGRES_USER=changemaker
|
||||||
|
V2_POSTGRES_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||||
|
V2_POSTGRES_DB=changemaker_v2
|
||||||
|
V2_POSTGRES_PORT=5433
|
||||||
|
|
||||||
|
# --- JWT Auth ---
|
||||||
|
JWT_ACCESS_SECRET=GENERATE_WITH_openssl_rand_hex_32
|
||||||
|
JWT_REFRESH_SECRET=GENERATE_WITH_openssl_rand_hex_32
|
||||||
|
JWT_ACCESS_EXPIRY=15m
|
||||||
|
JWT_REFRESH_EXPIRY=7d
|
||||||
|
|
||||||
|
# Encryption key for DB-stored secrets (SMTP password, etc.)
|
||||||
|
# REQUIRED in production — must NOT reuse JWT_ACCESS_SECRET
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
ENCRYPTION_KEY=GENERATE_WITH_openssl_rand_hex_32
|
||||||
|
|
||||||
|
# --- Initial Super Admin User (auto-created during database seeding) ---
|
||||||
|
# These credentials are used to create the initial super admin account
|
||||||
|
# Change these before running the seed script in production
|
||||||
|
INITIAL_ADMIN_EMAIL=admin@cmlite.org
|
||||||
|
INITIAL_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||||
|
|
||||||
|
# --- API ---
|
||||||
|
API_PORT=4000
|
||||||
|
API_URL=http://localhost:4000
|
||||||
|
CORS_ORIGINS=http://localhost:3000,http://localhost
|
||||||
|
|
||||||
|
# --- Admin GUI ---
|
||||||
|
ADMIN_PORT=3000
|
||||||
|
ADMIN_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# --- Nginx ---
|
||||||
|
NGINX_HTTP_PORT=80
|
||||||
|
NGINX_HTTPS_PORT=443
|
||||||
|
|
||||||
|
# --- SMTP / Email ---
|
||||||
|
SMTP_HOST=mailhog-changemaker
|
||||||
|
SMTP_PORT=1025
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_FROM=noreply@cmlite.org
|
||||||
|
SMTP_FROM_NAME=Changemaker Lite
|
||||||
|
EMAIL_TEST_MODE=true
|
||||||
|
TEST_EMAIL_RECIPIENT=admin@cmlite.org
|
||||||
|
|
||||||
|
# --- Listmonk ---
|
||||||
|
LISTMONK_PORT=9001
|
||||||
|
LISTMONK_DB_PORT=5432
|
||||||
|
LISTMONK_DB_USER=listmonk
|
||||||
|
LISTMONK_DB_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||||
|
LISTMONK_DB_NAME=listmonk
|
||||||
|
# Web admin login (for the Listmonk dashboard at :9001)
|
||||||
|
LISTMONK_WEB_ADMIN_USER=admin
|
||||||
|
LISTMONK_WEB_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||||
|
# API user (auto-created by listmonk-init container, used by V2 API for sync)
|
||||||
|
# Generate token: openssl rand -hex 16
|
||||||
|
LISTMONK_API_USER=v2-api
|
||||||
|
LISTMONK_API_TOKEN=GENERATE_WITH_openssl_rand_hex_16
|
||||||
|
LISTMONK_ADMIN_USER=v2-api
|
||||||
|
LISTMONK_ADMIN_PASSWORD=SAME_AS_LISTMONK_API_TOKEN
|
||||||
|
LISTMONK_SYNC_ENABLED=false
|
||||||
|
LISTMONK_PROXY_PORT=9002
|
||||||
|
# Listmonk SMTP — MailHog for development (production SMTP added as second provider if credentials set)
|
||||||
|
LISTMONK_SMTP_HOST=mailhog-changemaker
|
||||||
|
LISTMONK_SMTP_PORT=1025
|
||||||
|
LISTMONK_SMTP_USER=
|
||||||
|
LISTMONK_SMTP_PASSWORD=
|
||||||
|
LISTMONK_SMTP_TLS_TYPE=none
|
||||||
|
LISTMONK_SMTP_FROM=Changemaker Lite <noreply@cmlite.org>
|
||||||
|
# Production SMTP (uncomment and set for real email delivery):
|
||||||
|
# LISTMONK_SMTP_HOST=smtp.protonmail.ch
|
||||||
|
# LISTMONK_SMTP_PORT=587
|
||||||
|
# LISTMONK_SMTP_USER=your@email.com
|
||||||
|
# LISTMONK_SMTP_PASSWORD=your-password
|
||||||
|
# LISTMONK_SMTP_TLS_TYPE=STARTTLS
|
||||||
|
|
||||||
|
# --- Represent API (Canadian electoral data) ---
|
||||||
|
REPRESENT_API_URL=https://represent.opennorth.ca
|
||||||
|
|
||||||
|
# --- NocoDB v2 (read-only data browser) ---
|
||||||
|
# NocoDB uses its own database (nocodb_meta) to avoid conflicts with Prisma
|
||||||
|
# The database is auto-created by init-nocodb-db.sh on first PostgreSQL startup
|
||||||
|
NOCODB_V2_PORT=8091
|
||||||
|
NOCODB_URL=http://changemaker-v2-nocodb:8080
|
||||||
|
NOCODB_PORT=8091
|
||||||
|
NC_ADMIN_EMAIL=admin@cmlite.org
|
||||||
|
NC_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||||
|
|
||||||
|
# --- Redis ---
|
||||||
|
# Shared Redis (v2 uses authenticated connection)
|
||||||
|
REDIS_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||||
|
REDIS_URL=redis://:${REDIS_PASSWORD}@redis-changemaker:6379
|
||||||
|
|
||||||
|
# --- Media Management ---
|
||||||
|
ENABLE_MEDIA_FEATURES=false
|
||||||
|
MEDIA_API_PORT=4100
|
||||||
|
MEDIA_API_PUBLIC_URL=http://media-api:4100
|
||||||
|
MEDIA_ROOT=/media/library
|
||||||
|
MEDIA_UPLOADS=/media/uploads
|
||||||
|
MAX_UPLOAD_SIZE_GB=10
|
||||||
|
VIDEO_PLAYER_DEBUG=false
|
||||||
|
|
||||||
|
# Video Analytics (Feb 2026)
|
||||||
|
VIDEO_ANALYTICS_RETENTION_DAYS=90
|
||||||
|
VIDEO_ANALYTICS_IP_HASHING_ENABLED=true
|
||||||
|
|
||||||
|
# Video Scheduling (Feb 2026)
|
||||||
|
VIDEO_SCHEDULE_DEFAULT_TIMEZONE=UTC
|
||||||
|
VIDEO_SCHEDULE_NOTIFICATION_ENABLED=true
|
||||||
|
|
||||||
|
# Preview Links (Feb 2026)
|
||||||
|
VIDEO_PREVIEW_LINK_EXPIRY_HOURS=24
|
||||||
|
|
||||||
|
# --- Gitea ---
|
||||||
|
GITEA_URL=http://gitea-changemaker:3000
|
||||||
|
GITEA_PORT=3030
|
||||||
|
GITEA_WEB_PORT=3030
|
||||||
|
GITEA_SSH_PORT=2222
|
||||||
|
GITEA_DB_TYPE=mysql
|
||||||
|
GITEA_DB_HOST=gitea-db:3306
|
||||||
|
GITEA_DB_NAME=gitea
|
||||||
|
GITEA_DB_USER=gitea
|
||||||
|
GITEA_DB_PASSWD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||||
|
GITEA_DB_ROOT_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||||
|
GITEA_ROOT_URL=https://git.cmlite.org
|
||||||
|
GITEA_DOMAIN=git.cmlite.org
|
||||||
|
|
||||||
|
# --- n8n ---
|
||||||
|
N8N_URL=http://n8n-changemaker:5678
|
||||||
|
N8N_PORT=5678
|
||||||
|
N8N_HOST=n8n.cmlite.org
|
||||||
|
N8N_ENCRYPTION_KEY=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||||
|
N8N_USER_EMAIL=admin@example.com
|
||||||
|
N8N_USER_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||||
|
GENERIC_TIMEZONE=UTC
|
||||||
|
|
||||||
|
# --- MkDocs ---
|
||||||
|
# Port mapping for MkDocs container (host:container)
|
||||||
|
# This also controls the Vite dev proxy in local development
|
||||||
|
# Change this port to use a different local port, and the admin dev server will automatically use it
|
||||||
|
MKDOCS_PORT=4003
|
||||||
|
MKDOCS_SITE_SERVER_PORT=4001
|
||||||
|
BASE_DOMAIN=https://cmlite.org
|
||||||
|
MKDOCS_PREVIEW_URL=http://mkdocs:8000
|
||||||
|
MKDOCS_DOCS_PATH=/mkdocs/docs
|
||||||
|
|
||||||
|
# --- Code Server ---
|
||||||
|
CODE_SERVER_PORT=8888
|
||||||
|
CODE_SERVER_URL=http://code-server:8080
|
||||||
|
USER_NAME=coder
|
||||||
|
|
||||||
|
# --- Homepage ---
|
||||||
|
HOMEPAGE_PORT=3010
|
||||||
|
HOMEPAGE_VAR_BASE_URL=http://localhost
|
||||||
|
|
||||||
|
# --- Mini QR ---
|
||||||
|
MINI_QR_PORT=8089
|
||||||
|
MINI_QR_URL=http://mini-qr:8080
|
||||||
|
MINI_QR_EMBED_PORT=8885
|
||||||
|
|
||||||
|
# --- Excalidraw (Collaborative Whiteboard) ---
|
||||||
|
EXCALIDRAW_PORT=8090
|
||||||
|
EXCALIDRAW_URL=http://excalidraw-changemaker:80
|
||||||
|
EXCALIDRAW_EMBED_PORT=8886
|
||||||
|
EXCALIDRAW_WS_URL=wss://draw.cmlite.org
|
||||||
|
|
||||||
|
# --- MailHog ---
|
||||||
|
MAILHOG_SMTP_PORT=1025
|
||||||
|
MAILHOG_WEB_PORT=8025
|
||||||
|
|
||||||
|
# --- NAR (National Address Register) ---
|
||||||
|
# Path to extracted NAR data (contains YYYYMM/Addresses/ and YYYYMM/Locations/)
|
||||||
|
# Download from: https://www150.statcan.gc.ca/n1/pub/46-26-0002/462600022022001-eng.htm
|
||||||
|
NAR_DATA_DIR=/data
|
||||||
|
|
||||||
|
# --- Overpass / Area Import ---
|
||||||
|
# OpenStreetMap Overpass API endpoint (use a private instance for heavy usage)
|
||||||
|
OVERPASS_API_URL=https://overpass-api.de/api/interpreter
|
||||||
|
# Minimum delay between Overpass requests (ms) — public API requires 30s
|
||||||
|
OVERPASS_MIN_DELAY_MS=30000
|
||||||
|
# Maximum reverse geocode grid points for area import fill-in
|
||||||
|
AREA_IMPORT_MAX_GRID_POINTS=500
|
||||||
|
|
||||||
|
# --- Geocoding ---
|
||||||
|
# Optional Mapbox API key for improved geocoding accuracy
|
||||||
|
# Free tier: 100,000 requests/month
|
||||||
|
# Sign up: https://www.mapbox.com/pricing
|
||||||
|
MAPBOX_API_KEY=
|
||||||
|
# Rate limit delay between provider requests (milliseconds)
|
||||||
|
GEOCODING_RATE_LIMIT_MS=1100
|
||||||
|
# Redis-backed persistent cache settings
|
||||||
|
GEOCODING_CACHE_ENABLED=true
|
||||||
|
GEOCODING_CACHE_TTL_HOURS=24
|
||||||
|
# Phase 2: Performance & Accuracy
|
||||||
|
# Google Maps API (optional, most accurate but costs $0.005/request after 100k/month)
|
||||||
|
GOOGLE_MAPS_API_KEY=
|
||||||
|
GOOGLE_MAPS_ENABLED=false
|
||||||
|
# Parallel geocoding for bulk imports (10x speedup)
|
||||||
|
GEOCODING_PARALLEL_ENABLED=true
|
||||||
|
GEOCODING_BATCH_SIZE=10
|
||||||
|
|
||||||
|
# Bulk Re-Geocoding (Phase 3)
|
||||||
|
BULK_GEOCODE_ENABLED=true
|
||||||
|
BULK_GEOCODE_MAX_BATCH=5000
|
||||||
|
|
||||||
|
# --- Pangolin Tunnel ---
|
||||||
|
# Server: self-hosted Pangolin instance
|
||||||
|
PANGOLIN_API_URL=https://api.bnkserve.org/v1
|
||||||
|
PANGOLIN_API_KEY=
|
||||||
|
PANGOLIN_ORG_ID=
|
||||||
|
# Populated after setup (via admin GUI or API)
|
||||||
|
PANGOLIN_SITE_ID=
|
||||||
|
PANGOLIN_ENDPOINT=https://pangolin.bnkserve.org
|
||||||
|
PANGOLIN_NEWT_ID=
|
||||||
|
PANGOLIN_NEWT_SECRET=
|
||||||
|
|
||||||
|
# --- Monitoring (only used with --profile monitoring) ---
|
||||||
|
PROMETHEUS_PORT=9090
|
||||||
|
GRAFANA_PORT=3001
|
||||||
|
GRAFANA_ADMIN_PASSWORD=admin
|
||||||
|
GRAFANA_ROOT_URL=http://localhost:3001
|
||||||
|
CADVISOR_PORT=8080
|
||||||
|
NODE_EXPORTER_PORT=9100
|
||||||
|
REDIS_EXPORTER_PORT=9121
|
||||||
|
ALERTMANAGER_PORT=9093
|
||||||
|
GOTIFY_PORT=8889
|
||||||
|
GOTIFY_ADMIN_USER=admin
|
||||||
|
GOTIFY_ADMIN_PASSWORD=admin
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -21,6 +21,7 @@ node_modules/
|
|||||||
|
|
||||||
.env
|
.env
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
/configs/cloudflare/*.json
|
/configs/cloudflare/*.json
|
||||||
/configs/cloudflare/*.yaml
|
/configs/cloudflare/*.yaml
|
||||||
@ -34,3 +35,12 @@ node_modules/
|
|||||||
|
|
||||||
# NAR data directory (large voter registry files)
|
# NAR data directory (large voter registry files)
|
||||||
/data/
|
/data/
|
||||||
|
|
||||||
|
# Media files (managed by Docker volumes, not git)
|
||||||
|
/media/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
/admin/dist/
|
||||||
|
|
||||||
|
# MkDocs core binary
|
||||||
|
/mkdocs/core
|
||||||
|
|||||||
9
admin/.env.example
Normal file
9
admin/.env.example
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# API Configuration
|
||||||
|
# For Docker: http://changemaker-v2-api:4000
|
||||||
|
# For local dev: http://localhost:4000
|
||||||
|
VITE_API_URL=http://localhost:4000
|
||||||
|
|
||||||
|
# MkDocs Configuration
|
||||||
|
# For Docker: http://mkdocs-changemaker:8000
|
||||||
|
# For local dev: http://localhost:4003
|
||||||
|
VITE_MKDOCS_URL=http://localhost:4003
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Steps,
|
Steps,
|
||||||
Button,
|
Button,
|
||||||
@ -26,7 +26,11 @@ import {
|
|||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
MinusCircleOutlined,
|
MinusCircleOutlined,
|
||||||
|
WarningOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
import { MapContainer, TileLayer, Rectangle, Polygon, useMap } from 'react-leaflet';
|
||||||
|
import type { LatLngBoundsExpression } from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type {
|
import type {
|
||||||
Cut,
|
Cut,
|
||||||
@ -51,6 +55,56 @@ const SOURCE_STATUS_ICONS: Record<AreaImportSourceStatus, React.ReactNode> = {
|
|||||||
skipped: <MinusCircleOutlined style={{ color: '#d9d9d9' }} />,
|
skipped: <MinusCircleOutlined style={{ color: '#d9d9d9' }} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ZOOM_PRESETS = [
|
||||||
|
{ value: 15, label: 'Neighborhood (zoom 15)' },
|
||||||
|
{ value: 13, label: 'District (zoom 13)' },
|
||||||
|
{ value: 10, label: 'City-wide (zoom 10)' },
|
||||||
|
{ value: 8, label: 'Region (zoom 8)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side bounding box calculation from center + zoom + viewport.
|
||||||
|
* Mirrors the server-side boundsFromCenterZoom() in spatial.ts.
|
||||||
|
*/
|
||||||
|
function boundsFromCenterZoom(
|
||||||
|
lat: number,
|
||||||
|
lng: number,
|
||||||
|
zoom: number,
|
||||||
|
viewportWidth = 1024,
|
||||||
|
viewportHeight = 768,
|
||||||
|
): { minLat: number; maxLat: number; minLng: number; maxLng: number } {
|
||||||
|
const metersPerPixel = (156543.03392 * Math.cos((lat * Math.PI) / 180)) / Math.pow(2, zoom);
|
||||||
|
const halfWidthMeters = (viewportWidth / 2) * metersPerPixel;
|
||||||
|
const halfHeightMeters = (viewportHeight / 2) * metersPerPixel;
|
||||||
|
const latDelta = halfHeightMeters / 111320;
|
||||||
|
const lngDelta = halfWidthMeters / (111320 * Math.cos((lat * Math.PI) / 180));
|
||||||
|
return {
|
||||||
|
minLat: lat - latDelta,
|
||||||
|
maxLat: lat + latDelta,
|
||||||
|
minLng: lng - lngDelta,
|
||||||
|
maxLng: lng + lngDelta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approximate area in sq km from a bounding box.
|
||||||
|
*/
|
||||||
|
function areaSqKm(bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }): number {
|
||||||
|
const latMeters = (bounds.maxLat - bounds.minLat) * 111320;
|
||||||
|
const avgLat = (bounds.minLat + bounds.maxLat) / 2;
|
||||||
|
const lngMeters = (bounds.maxLng - bounds.minLng) * 111320 * Math.cos((avgLat * Math.PI) / 180);
|
||||||
|
return (latMeters * lngMeters) / 1_000_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fit the map to given bounds whenever they change. */
|
||||||
|
function FitBoundsUpdater({ bounds }: { bounds: LatLngBoundsExpression }) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
map.fitBounds(bounds, { padding: [20, 20] });
|
||||||
|
}, [map, bounds]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardProps) {
|
export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardProps) {
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
|
||||||
@ -59,6 +113,7 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP
|
|||||||
const [selectedCutId, setSelectedCutId] = useState<string | undefined>();
|
const [selectedCutId, setSelectedCutId] = useState<string | undefined>();
|
||||||
const [mapSettings, setMapSettings] = useState<MapSettings | null>(null);
|
const [mapSettings, setMapSettings] = useState<MapSettings | null>(null);
|
||||||
const [mapSettingsLoading, setMapSettingsLoading] = useState(false);
|
const [mapSettingsLoading, setMapSettingsLoading] = useState(false);
|
||||||
|
const [zoomOverride, setZoomOverride] = useState<number | null>(null);
|
||||||
|
|
||||||
// Step 1: Sources
|
// Step 1: Sources
|
||||||
const [osmEnabled, setOsmEnabled] = useState(true);
|
const [osmEnabled, setOsmEnabled] = useState(true);
|
||||||
@ -78,6 +133,9 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP
|
|||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
const pollRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||||
|
|
||||||
|
// Effective zoom for viewport mode (override or map settings default)
|
||||||
|
const effectiveZoom = zoomOverride ?? mapSettings?.zoom ?? 13;
|
||||||
|
|
||||||
// Load map settings for viewport mode
|
// Load map settings for viewport mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (areaType === 'viewport' && !mapSettings) {
|
if (areaType === 'viewport' && !mapSettings) {
|
||||||
@ -96,6 +154,54 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Compute viewport bounds for preview map
|
||||||
|
const viewportBounds = useMemo(() => {
|
||||||
|
if (areaType !== 'viewport' || !mapSettings?.latitude || !mapSettings?.longitude) return null;
|
||||||
|
return boundsFromCenterZoom(
|
||||||
|
Number(mapSettings.latitude),
|
||||||
|
Number(mapSettings.longitude),
|
||||||
|
effectiveZoom,
|
||||||
|
);
|
||||||
|
}, [areaType, mapSettings?.latitude, mapSettings?.longitude, effectiveZoom]);
|
||||||
|
|
||||||
|
const viewportAreaSqKm = viewportBounds ? areaSqKm(viewportBounds) : null;
|
||||||
|
|
||||||
|
// Parse cut polygon for preview map
|
||||||
|
const selectedCutGeoJson = useMemo(() => {
|
||||||
|
if (areaType !== 'cut' || !selectedCutId) return null;
|
||||||
|
const cut = cuts.find((c) => c.id === selectedCutId);
|
||||||
|
if (!cut?.geojson) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cut.geojson);
|
||||||
|
// GeoJSON coordinates are [lng, lat] — Leaflet Polygon needs [lat, lng]
|
||||||
|
if (parsed.type === 'Polygon' && parsed.coordinates) {
|
||||||
|
return parsed.coordinates[0].map((c: number[]) => [c[1], c[0]] as [number, number]);
|
||||||
|
}
|
||||||
|
if (parsed.type === 'MultiPolygon' && parsed.coordinates) {
|
||||||
|
// Just use the first polygon ring for preview
|
||||||
|
return parsed.coordinates[0][0].map((c: number[]) => [c[1], c[0]] as [number, number]);
|
||||||
|
}
|
||||||
|
// If it's just an array of coordinate rings (legacy format)
|
||||||
|
if (Array.isArray(parsed) && Array.isArray(parsed[0])) {
|
||||||
|
return parsed[0].map((c: number[]) => [c[1], c[0]] as [number, number]);
|
||||||
|
}
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
return null;
|
||||||
|
}, [areaType, selectedCutId, cuts]);
|
||||||
|
|
||||||
|
// Cut bounds for fitBounds
|
||||||
|
const cutPreviewBounds = useMemo((): LatLngBoundsExpression | null => {
|
||||||
|
if (!selectedCutGeoJson || selectedCutGeoJson.length === 0) return null;
|
||||||
|
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
|
||||||
|
for (const [lat, lng] of selectedCutGeoJson) {
|
||||||
|
if (lat < minLat) minLat = lat;
|
||||||
|
if (lat > maxLat) maxLat = lat;
|
||||||
|
if (lng < minLng) minLng = lng;
|
||||||
|
if (lng > maxLng) maxLng = lng;
|
||||||
|
}
|
||||||
|
return [[minLat, minLng], [maxLat, maxLng]];
|
||||||
|
}, [selectedCutGeoJson]);
|
||||||
|
|
||||||
const buildRequestBody = useCallback(() => {
|
const buildRequestBody = useCallback(() => {
|
||||||
const sources: Record<string, unknown> = {
|
const sources: Record<string, unknown> = {
|
||||||
osm: osmEnabled,
|
osm: osmEnabled,
|
||||||
@ -114,11 +220,11 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP
|
|||||||
lat: mapSettings?.latitude ? Number(mapSettings.latitude) : 53.5,
|
lat: mapSettings?.latitude ? Number(mapSettings.latitude) : 53.5,
|
||||||
lng: mapSettings?.longitude ? Number(mapSettings.longitude) : -113.5,
|
lng: mapSettings?.longitude ? Number(mapSettings.longitude) : -113.5,
|
||||||
};
|
};
|
||||||
body.zoom = mapSettings?.zoom ?? 13;
|
body.zoom = effectiveZoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
return body;
|
return body;
|
||||||
}, [areaType, selectedCutId, mapSettings, osmEnabled, narEnabled, narResidentialOnly, rgEnabled, rgSpacing, rgMaxPoints]);
|
}, [areaType, selectedCutId, mapSettings, effectiveZoom, osmEnabled, narEnabled, narResidentialOnly, rgEnabled, rgSpacing, rgMaxPoints]);
|
||||||
|
|
||||||
const fetchPreview = async () => {
|
const fetchPreview = async () => {
|
||||||
setPreviewLoading(true);
|
setPreviewLoading(true);
|
||||||
@ -162,6 +268,94 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP
|
|||||||
const canProceedStep0 = areaType === 'cut' ? !!selectedCutId : (!!mapSettings?.latitude && !!mapSettings?.longitude);
|
const canProceedStep0 = areaType === 'cut' ? !!selectedCutId : (!!mapSettings?.latitude && !!mapSettings?.longitude);
|
||||||
const canProceedStep1 = osmEnabled || narEnabled || rgEnabled;
|
const canProceedStep1 = osmEnabled || narEnabled || rgEnabled;
|
||||||
|
|
||||||
|
// ─── Mini Map Preview ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const renderPreviewMap = () => {
|
||||||
|
// Viewport mode: show rectangle
|
||||||
|
if (areaType === 'viewport' && viewportBounds) {
|
||||||
|
const mapBounds: LatLngBoundsExpression = [
|
||||||
|
[viewportBounds.minLat, viewportBounds.minLng],
|
||||||
|
[viewportBounds.maxLat, viewportBounds.maxLng],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Card size="small" title="Import Area Preview" style={{ marginTop: 12 }}>
|
||||||
|
<div style={{ height: 250 }}>
|
||||||
|
<MapContainer
|
||||||
|
bounds={mapBounds}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
zoomControl={false}
|
||||||
|
dragging={false}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
doubleClickZoom={false}
|
||||||
|
touchZoom={false}
|
||||||
|
boxZoom={false}
|
||||||
|
keyboard={false}
|
||||||
|
attributionControl={false}
|
||||||
|
>
|
||||||
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||||
|
<FitBoundsUpdater bounds={mapBounds} />
|
||||||
|
<Rectangle
|
||||||
|
bounds={mapBounds}
|
||||||
|
pathOptions={{
|
||||||
|
color: '#1890ff',
|
||||||
|
weight: 2,
|
||||||
|
dashArray: '6 4',
|
||||||
|
fillColor: '#1890ff',
|
||||||
|
fillOpacity: 0.1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cut mode: show polygon
|
||||||
|
if (areaType === 'cut' && selectedCutGeoJson && cutPreviewBounds) {
|
||||||
|
return (
|
||||||
|
<Card size="small" title="Import Area Preview" style={{ marginTop: 12 }}>
|
||||||
|
<div style={{ height: 250 }}>
|
||||||
|
<MapContainer
|
||||||
|
bounds={cutPreviewBounds}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
zoomControl={false}
|
||||||
|
dragging={false}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
doubleClickZoom={false}
|
||||||
|
touchZoom={false}
|
||||||
|
boxZoom={false}
|
||||||
|
keyboard={false}
|
||||||
|
attributionControl={false}
|
||||||
|
>
|
||||||
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||||
|
<FitBoundsUpdater bounds={cutPreviewBounds} />
|
||||||
|
<Polygon
|
||||||
|
positions={selectedCutGeoJson}
|
||||||
|
pathOptions={{
|
||||||
|
color: '#1890ff',
|
||||||
|
weight: 2,
|
||||||
|
fillColor: '#1890ff',
|
||||||
|
fillOpacity: 0.15,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No area configured yet
|
||||||
|
return (
|
||||||
|
<Card size="small" style={{ marginTop: 12 }}>
|
||||||
|
<Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: 16 }}>
|
||||||
|
Select an area type above to see preview
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Steps ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
title: 'Define Area',
|
title: 'Define Area',
|
||||||
@ -202,22 +396,56 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP
|
|||||||
{mapSettingsLoading ? (
|
{mapSettingsLoading ? (
|
||||||
<Spin size="small" />
|
<Spin size="small" />
|
||||||
) : mapSettings?.latitude && mapSettings?.longitude ? (
|
) : mapSettings?.latitude && mapSettings?.longitude ? (
|
||||||
<Card size="small">
|
<>
|
||||||
<Row gutter={16}>
|
<Card size="small">
|
||||||
<Col span={8}>
|
<Row gutter={16}>
|
||||||
<Statistic title="Center Lat" value={Number(mapSettings.latitude).toFixed(4)} valueStyle={{ fontSize: 16 }} />
|
<Col span={6}>
|
||||||
</Col>
|
<Statistic title="Center Lat" value={Number(mapSettings.latitude).toFixed(4)} valueStyle={{ fontSize: 16 }} />
|
||||||
<Col span={8}>
|
</Col>
|
||||||
<Statistic title="Center Lng" value={Number(mapSettings.longitude).toFixed(4)} valueStyle={{ fontSize: 16 }} />
|
<Col span={6}>
|
||||||
</Col>
|
<Statistic title="Center Lng" value={Number(mapSettings.longitude).toFixed(4)} valueStyle={{ fontSize: 16 }} />
|
||||||
<Col span={8}>
|
</Col>
|
||||||
<Statistic title="Zoom" value={mapSettings.zoom ?? 13} valueStyle={{ fontSize: 16 }} />
|
<Col span={6}>
|
||||||
</Col>
|
<Statistic title="Zoom" value={effectiveZoom} valueStyle={{ fontSize: 16 }} />
|
||||||
</Row>
|
</Col>
|
||||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 8, display: 'block' }}>
|
<Col span={6}>
|
||||||
A bounding box will be derived from the map center and zoom level.
|
<Statistic
|
||||||
</Text>
|
title="Area"
|
||||||
</Card>
|
value={viewportAreaSqKm ? viewportAreaSqKm.toFixed(0) : '—'}
|
||||||
|
suffix="km2"
|
||||||
|
valueStyle={{ fontSize: 16 }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Text strong style={{ display: 'block', marginBottom: 4 }}>Zoom Level Override:</Text>
|
||||||
|
<Select
|
||||||
|
value={effectiveZoom}
|
||||||
|
onChange={(val) => setZoomOverride(val)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={[
|
||||||
|
{ value: mapSettings.zoom ?? 13, label: `Map Settings Default (zoom ${mapSettings.zoom ?? 13})` },
|
||||||
|
...ZOOM_PRESETS.filter((p) => p.value !== (mapSettings.zoom ?? 13)),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||||
|
Lower zoom = larger area. Use "City-wide" for full city imports.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewportAreaSqKm !== null && viewportAreaSqKm < 50 && (
|
||||||
|
<Alert
|
||||||
|
message="Small Coverage Area"
|
||||||
|
description={`This covers only ${viewportAreaSqKm.toFixed(0)} km2. Consider using a lower zoom level for city-wide imports.`}
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
icon={<WarningOutlined />}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Alert
|
<Alert
|
||||||
message="Map settings not configured"
|
message="Map settings not configured"
|
||||||
@ -228,6 +456,8 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{renderPreviewMap()}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -479,6 +709,9 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP
|
|||||||
{sp.candidatesFound > 0 && (
|
{sp.candidatesFound > 0 && (
|
||||||
<Text type="secondary">{sp.candidatesFound} found</Text>
|
<Text type="secondary">{sp.candidatesFound} found</Text>
|
||||||
)}
|
)}
|
||||||
|
{sp.failedQuadrants && sp.failedQuadrants > 0 && (
|
||||||
|
<Tag color="warning">{sp.failedQuadrants} quadrant(s) failed</Tag>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
{sp.message && (
|
{sp.message && (
|
||||||
<Text type="secondary" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
|
<Text type="secondary" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
|
||||||
|
|||||||
@ -198,7 +198,7 @@ export default function VolunteerMapPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Zoom-based limits (from v1 optimization)
|
// Zoom-based limits (from v1 optimization)
|
||||||
const limit = zoom >= 15 ? 2000 : zoom >= 12 ? 1000 : 500;
|
const limit = zoom >= 15 ? 5000 : zoom >= 12 ? 3000 : 1500;
|
||||||
|
|
||||||
clearTimeout(fetchTimerRef.current);
|
clearTimeout(fetchTimerRef.current);
|
||||||
fetchTimerRef.current = setTimeout(() => {
|
fetchTimerRef.current = setTimeout(() => {
|
||||||
|
|||||||
@ -1286,6 +1286,7 @@ export type AreaImportSourceStatus = 'pending' | 'running' | 'complete' | 'faile
|
|||||||
export interface AreaImportSourceProgress {
|
export interface AreaImportSourceProgress {
|
||||||
status: AreaImportSourceStatus;
|
status: AreaImportSourceStatus;
|
||||||
candidatesFound: number;
|
candidatesFound: number;
|
||||||
|
failedQuadrants?: number;
|
||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export type SourceStatus = 'pending' | 'running' | 'complete' | 'failed' | 'skip
|
|||||||
export interface AreaImportSourceProgress {
|
export interface AreaImportSourceProgress {
|
||||||
status: SourceStatus;
|
status: SourceStatus;
|
||||||
candidatesFound: number;
|
candidatesFound: number;
|
||||||
|
failedQuadrants?: number;
|
||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@ -333,24 +334,28 @@ export const areaImportService = {
|
|||||||
progress.sources.osm.status = 'running';
|
progress.sources.osm.status = 'running';
|
||||||
await updateProgress();
|
await updateProgress();
|
||||||
try {
|
try {
|
||||||
const osmCandidates = await overpassService.queryArea(bounds, (msg) => {
|
const osmResult = await overpassService.queryArea(bounds, (msg) => {
|
||||||
progress.sources.osm.message = msg;
|
progress.sources.osm.message = msg;
|
||||||
writeProgress(importId, progress).catch(() => {});
|
writeProgress(importId, progress).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter by cut polygon if applicable
|
// Filter by cut polygon if applicable
|
||||||
let filtered = osmCandidates;
|
let filtered = osmResult.candidates;
|
||||||
if (cutPolygons && cutPolygons.length > 0) {
|
if (cutPolygons && cutPolygons.length > 0) {
|
||||||
filtered = osmCandidates.filter((c) =>
|
filtered = osmResult.candidates.filter((c) =>
|
||||||
cutPolygons.some((ring) => isPointInPolygon(c.latitude, c.longitude, ring)),
|
cutPolygons.some((ring) => isPointInPolygon(c.latitude, c.longitude, ring)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
allCandidates.push(...filtered);
|
for (const c of filtered) allCandidates.push(c);
|
||||||
progress.sources.osm.status = 'complete';
|
progress.sources.osm.status = 'complete';
|
||||||
progress.sources.osm.candidatesFound = filtered.length;
|
progress.sources.osm.candidatesFound = filtered.length;
|
||||||
|
if (osmResult.failedQuadrants > 0) {
|
||||||
|
progress.sources.osm.failedQuadrants = osmResult.failedQuadrants;
|
||||||
|
progress.sources.osm.message = `${filtered.length} found, ${osmResult.failedQuadrants} quadrant(s) failed`;
|
||||||
|
}
|
||||||
await updateProgress();
|
await updateProgress();
|
||||||
logger.info(`OSM source: ${filtered.length} candidates (${osmCandidates.length} pre-filter)`);
|
logger.info(`OSM source: ${filtered.length} candidates (${osmResult.candidates.length} pre-filter, ${osmResult.failedQuadrants} failed quadrants)`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
progress.sources.osm.status = 'failed';
|
progress.sources.osm.status = 'failed';
|
||||||
@ -469,7 +474,7 @@ export const areaImportService = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allCandidates.push(...narCandidates);
|
for (const c of narCandidates) allCandidates.push(c);
|
||||||
progress.sources.nar.status = 'complete';
|
progress.sources.nar.status = 'complete';
|
||||||
progress.sources.nar.candidatesFound = narCandidates.length;
|
progress.sources.nar.candidatesFound = narCandidates.length;
|
||||||
await updateProgress();
|
await updateProgress();
|
||||||
@ -564,7 +569,7 @@ export const areaImportService = {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||||
}
|
}
|
||||||
|
|
||||||
allCandidates.push(...rgCandidates);
|
for (const c of rgCandidates) allCandidates.push(c);
|
||||||
progress.sources.reverseGeocode.status = 'complete';
|
progress.sources.reverseGeocode.status = 'complete';
|
||||||
progress.sources.reverseGeocode.candidatesFound = rgCandidates.length;
|
progress.sources.reverseGeocode.candidatesFound = rgCandidates.length;
|
||||||
await updateProgress();
|
await updateProgress();
|
||||||
|
|||||||
@ -172,11 +172,12 @@ export const overpassService = {
|
|||||||
/**
|
/**
|
||||||
* Query all address data within a bounding box.
|
* Query all address data within a bounding box.
|
||||||
* Automatically splits large areas into sub-quadrants.
|
* Automatically splits large areas into sub-quadrants.
|
||||||
|
* Returns candidates and the number of quadrants that failed.
|
||||||
*/
|
*/
|
||||||
async queryArea(
|
async queryArea(
|
||||||
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
|
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
|
||||||
onProgress?: (msg: string) => void,
|
onProgress?: (msg: string) => void,
|
||||||
): Promise<CandidateLocation[]> {
|
): Promise<{ candidates: CandidateLocation[]; failedQuadrants: number }> {
|
||||||
const area = areaSqDeg(bounds);
|
const area = areaSqDeg(bounds);
|
||||||
|
|
||||||
// If area is too large, split into quadrants and query each
|
// If area is too large, split into quadrants and query each
|
||||||
@ -184,20 +185,23 @@ export const overpassService = {
|
|||||||
const quadrants = splitBounds(bounds);
|
const quadrants = splitBounds(bounds);
|
||||||
const allCandidates: CandidateLocation[] = [];
|
const allCandidates: CandidateLocation[] = [];
|
||||||
const totalQuadrants = quadrants.length;
|
const totalQuadrants = quadrants.length;
|
||||||
|
let failedQuadrants = 0;
|
||||||
|
|
||||||
for (let i = 0; i < totalQuadrants; i++) {
|
for (let i = 0; i < totalQuadrants; i++) {
|
||||||
onProgress?.(`Querying OSM quadrant ${i + 1}/${totalQuadrants}`);
|
onProgress?.(`Querying OSM quadrant ${i + 1}/${totalQuadrants}`);
|
||||||
try {
|
try {
|
||||||
const subCandidates = await this.queryArea(quadrants[i]!, onProgress);
|
const result = await this.queryArea(quadrants[i]!, onProgress);
|
||||||
allCandidates.push(...subCandidates);
|
for (const c of result.candidates) allCandidates.push(c);
|
||||||
|
failedQuadrants += result.failedQuadrants;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
logger.warn(`Overpass quadrant ${i + 1} failed: ${msg}`);
|
logger.warn(`Overpass quadrant ${i + 1} failed: ${msg}`);
|
||||||
|
failedQuadrants++;
|
||||||
// Continue with other quadrants
|
// Continue with other quadrants
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return allCandidates;
|
return { candidates: allCandidates, failedQuadrants };
|
||||||
}
|
}
|
||||||
|
|
||||||
const bbox = `${bounds.minLat},${bounds.minLng},${bounds.maxLat},${bounds.maxLng}`;
|
const bbox = `${bounds.minLat},${bounds.minLng},${bounds.maxLat},${bounds.maxLng}`;
|
||||||
@ -206,6 +210,6 @@ export const overpassService = {
|
|||||||
onProgress?.('Querying OSM addresses...');
|
onProgress?.('Querying OSM addresses...');
|
||||||
const data = await queryOverpass<OverpassResponse>(query);
|
const data = await queryOverpass<OverpassResponse>(query);
|
||||||
|
|
||||||
return parseElements(data.elements);
|
return { candidates: parseElements(data.elements), failedQuadrants: 0 };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
291
media-manager/.env.example
Normal file
291
media-manager/.env.example
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# REQUIRED SECRETS - You MUST change these before starting!
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# PostgreSQL password - REQUIRED
|
||||||
|
# Generate: openssl rand -base64 24
|
||||||
|
POSTGRES_PASSWORD=CHANGE_ME_REQUIRED
|
||||||
|
|
||||||
|
# JWT secret for authentication - REQUIRED (min 32 characters)
|
||||||
|
# Generate: openssl rand -hex 32
|
||||||
|
JWT_SECRET=CHANGE_ME_REQUIRED_MIN_32_CHARS
|
||||||
|
|
||||||
|
# Initial admin account (created on first startup if no admin exists)
|
||||||
|
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||||
|
INITIAL_ADMIN_PASSWORD=ChangeMe123!
|
||||||
|
|
||||||
|
# SMTP Configuration (required for email verification and password reset)
|
||||||
|
# Leave empty to disable email features (admin approval will be used instead)
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=your-smtp-username
|
||||||
|
SMTP_PASS=your-smtp-password
|
||||||
|
SMTP_FROM=noreply@example.com
|
||||||
|
SMTP_FROM_NAME=Media Manager
|
||||||
|
|
||||||
|
# Email settings
|
||||||
|
EMAIL_VERIFICATION_EXPIRY_HOURS=24
|
||||||
|
PASSWORD_RESET_EXPIRY_HOURS=1
|
||||||
|
|
||||||
|
# Application URL (used in email links)
|
||||||
|
APP_BASE_URL=http://localhost:3080
|
||||||
|
|
||||||
|
# CORS allowed origins (comma-separated)
|
||||||
|
# Add your production domains here
|
||||||
|
ALLOWED_ORIGINS=http://localhost:3080,http://localhost:8080
|
||||||
|
|
||||||
|
# Timezone for API server (logs and server-side timestamps)
|
||||||
|
# See: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||||
|
TZ=UTC
|
||||||
|
|
||||||
|
# Locale for date/time formatting (optional, defaults to browser locale)
|
||||||
|
# Examples: en-US, en-GB, de-DE, fr-FR
|
||||||
|
VITE_DEFAULT_LOCALE=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MAXMIND GEOIP CONFIGURATION (optional)
|
||||||
|
# =============================================================================
|
||||||
|
# Provides geographic location data for session analytics
|
||||||
|
# Download GeoLite2-City.mmdb from: https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
|
||||||
|
# Place the file in ./data/GeoLite2-City.mmdb (mounted to /app/data in container)
|
||||||
|
# Register for free account to download: https://www.maxmind.com/en/geolite2/signup
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CONTENT SAFETY CONFIGURATION (requires --profile safety)
|
||||||
|
# =============================================================================
|
||||||
|
# Set to false to disable LLM-based content safety checking
|
||||||
|
SAFETY_CHECK_ENABLED=true
|
||||||
|
|
||||||
|
# Admin email for high-severity content alerts (e.g., S4 Child Sexual Exploitation)
|
||||||
|
# If not set, falls back to SMTP_FROM
|
||||||
|
# Leave empty to disable high-severity email alerts
|
||||||
|
ADMIN_ALERT_EMAIL=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# OLLAMA CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
# Base URL for Ollama API (shared by safety and digest features)
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
|
||||||
|
# Content Safety Model (Llama Guard for chat moderation)
|
||||||
|
OLLAMA_MODEL=llama-guard3:1b
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DIGEST FEATURE MODELS (requires --profile digest)
|
||||||
|
# =============================================================================
|
||||||
|
# Video analysis pipeline using vision-language models and speech recognition
|
||||||
|
#
|
||||||
|
# Vision Model - analyzes video frames (requires vision-language model)
|
||||||
|
# Used for: frame description, visual element detection, scene classification
|
||||||
|
# Options:
|
||||||
|
# - huihui_ai/qwen3-vl-abliterated:2b (uncensored, ~2GB VRAM) [RECOMMENDED]
|
||||||
|
# - qwen2-vl:7b (censored, ~7GB VRAM)
|
||||||
|
# - llava:7b (censored, ~7GB VRAM)
|
||||||
|
DIGEST_VISION_MODEL=huihui_ai/qwen3-vl-abliterated:2b
|
||||||
|
|
||||||
|
# Text Model - synthesizes analysis results (text-only LLM)
|
||||||
|
# Used for: tag extraction, transcript analysis, final synthesis
|
||||||
|
# Options:
|
||||||
|
# - huihui_ai/qwen3-abliterated:4b (uncensored, ~4GB VRAM) [RECOMMENDED]
|
||||||
|
# - qwen2.5:7b (censored, ~7GB VRAM)
|
||||||
|
# - llama3.1:8b (censored, ~8GB VRAM)
|
||||||
|
DIGEST_TEXT_MODEL=huihui_ai/qwen3-abliterated:4b
|
||||||
|
|
||||||
|
# Digest service URLs
|
||||||
|
DIGEST_OLLAMA_URL=http://ollama:11434
|
||||||
|
DIGEST_WHISPER_URL=http://whisper:5000
|
||||||
|
|
||||||
|
# Frame extraction interval (seconds between frames)
|
||||||
|
DIGEST_FRAME_INTERVAL=30
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# JOYCAPTION VISION BACKEND (requires --profile joycaption)
|
||||||
|
# =============================================================================
|
||||||
|
# Alternative to Ollama vision models - uses JoyCaption GGUF via llama.cpp
|
||||||
|
# Better quality image captioning, fits on 6GB VRAM with Q3_K_M quantization
|
||||||
|
#
|
||||||
|
# Vision backend type: 'ollama' (default) or 'llama-server' (JoyCaption)
|
||||||
|
DIGEST_VISION_BACKEND=ollama
|
||||||
|
|
||||||
|
# JoyCaption server URL (when using llama-server backend)
|
||||||
|
JOYCAPTION_URL=http://joycaption:8080
|
||||||
|
JOYCAPTION_MODEL=llama-joycaption-beta-one-hf-llava
|
||||||
|
|
||||||
|
# Fall back to Ollama vision if JoyCaption fails
|
||||||
|
JOYCAPTION_FALLBACK_TO_OLLAMA=true
|
||||||
|
|
||||||
|
# JoyCaption model files (place in ./models directory)
|
||||||
|
# Download from: https://huggingface.co/Mungert/llama-joycaption-beta-one-hf-llava-GGUF
|
||||||
|
# Quantization options:
|
||||||
|
# - Q2_K: ~3.2GB model, ~4GB VRAM (good quality, safest for 6GB GPU)
|
||||||
|
# - Q3_K_M: ~4GB model, ~5GB VRAM (better quality, tight fit on 6GB)
|
||||||
|
# - Q4_K_M: ~5GB model, ~6GB VRAM (best quality, needs 8GB+ GPU)
|
||||||
|
JOYCAPTION_MODEL_PATH=/models/llama-joycaption-beta-one-hf-llava.Q2_K.gguf
|
||||||
|
JOYCAPTION_MMPROJ_PATH=/models/llama-joycaption-beta-one-llava-mmproj-model-f16.gguf
|
||||||
|
|
||||||
|
# llama-server configuration
|
||||||
|
JOYCAPTION_CTX_SIZE=4096
|
||||||
|
JOYCAPTION_GPU_LAYERS=999
|
||||||
|
JOYCAPTION_PARALLEL=1
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WHISPER TRANSCRIPTION
|
||||||
|
# =============================================================================
|
||||||
|
# Model sizes: tiny, base, small, medium, large-v2, large-v3
|
||||||
|
# Larger = more accurate but slower and more VRAM
|
||||||
|
WHISPER_MODEL=base
|
||||||
|
WHISPER_DEVICE=cuda
|
||||||
|
WHISPER_COMPUTE=float16
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# OLLAMA AUTO-PULL MODELS
|
||||||
|
# =============================================================================
|
||||||
|
# Comma-separated list of models to pull on Ollama container startup
|
||||||
|
# Include all models needed for your enabled features
|
||||||
|
#
|
||||||
|
# Safety profile only:
|
||||||
|
# OLLAMA_MODELS=llama-guard3:1b
|
||||||
|
#
|
||||||
|
# Digest profile only:
|
||||||
|
# OLLAMA_MODELS=huihui_ai/qwen3-vl-abliterated:2b,huihui_ai/qwen3-abliterated:4b
|
||||||
|
#
|
||||||
|
# Both profiles (recommended):
|
||||||
|
OLLAMA_MODELS=llama-guard3:1b,huihui_ai/qwen3-vl-abliterated:2b,huihui_ai/qwen3-abliterated:4b
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# JOB QUEUE CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
# GPU resource management for preventing job conflicts
|
||||||
|
# The system automatically queues GPU-intensive jobs to prevent VRAM exhaustion
|
||||||
|
|
||||||
|
# Maximum GPU VRAM in MB (default: 6000 for RTX 4050)
|
||||||
|
# Adjust based on your GPU: RTX 3090 = 24000, RTX 4080 = 16000, etc.
|
||||||
|
MAX_GPU_VRAM=6000
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FFMPEG ENCODING CONCURRENCY
|
||||||
|
# =============================================================================
|
||||||
|
# Number of parallel FFmpeg encode sessions for clip/scene generation.
|
||||||
|
# NVENC supports 3-5 concurrent sessions on consumer RTX GPUs (~200-400MB VRAM each).
|
||||||
|
# Set to 1 to restore sequential behavior.
|
||||||
|
FFMPEG_ENCODE_CONCURRENCY=2
|
||||||
|
|
||||||
|
# Disable job queue for immediate execution (legacy behavior)
|
||||||
|
# Set to 'true' to bypass the queue manager entirely
|
||||||
|
# DISABLE_JOB_QUEUE=false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# INBOX FILE WATCHER CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
# Automatically watches inbox directory for new video files.
|
||||||
|
# When a new file is fully written, it's scanned into the library
|
||||||
|
# and optionally triggers auto-digest if auto_digest_enabled is true.
|
||||||
|
|
||||||
|
# Enable/disable file watcher (default: false, can also enable via admin UI)
|
||||||
|
# Set to 'true' to auto-detect new files added to inbox directory
|
||||||
|
FILE_WATCHER_ENABLED=false
|
||||||
|
|
||||||
|
# Time in ms to wait for file size to stabilize before processing (default: 2000)
|
||||||
|
# Increase for slower network drives or when copying large files
|
||||||
|
FILE_WATCHER_DEBOUNCE_MS=2000
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CONTAINER LIFECYCLE CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
# By default, the system starts AI containers on-demand and stops them after use
|
||||||
|
# to minimize VRAM usage. Set to 'true' to disable this behavior when all AI
|
||||||
|
# containers are configured to run continuously (e.g., models fit in VRAM).
|
||||||
|
#
|
||||||
|
# When enabled:
|
||||||
|
# - ensureContainerRunning() only checks health, doesn't start containers
|
||||||
|
# - stopContainer() becomes a no-op, containers keep running
|
||||||
|
# - You must manually start containers: docker compose --profile ai up -d
|
||||||
|
DISABLE_CONTAINER_LIFECYCLE=false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# COMBINED SCENE DETECTION CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
# Uses three methods: TransNetV2 (neural), PySceneDetect (histogram), CLIP (semantic)
|
||||||
|
# Results are merged using configurable strategies for more accurate scene boundaries.
|
||||||
|
#
|
||||||
|
# Enable/disable combined detection (default: true)
|
||||||
|
# When disabled, falls back to PySceneDetect only
|
||||||
|
ENABLE_COMBINED_SCENE_DETECTION=true
|
||||||
|
|
||||||
|
# Merge strategy for combining cuts from multiple detectors
|
||||||
|
# Options: weighted (default), union, intersection, majority
|
||||||
|
# - weighted: Uses detector confidence weights (best balance)
|
||||||
|
# - union: Include all cuts (most comprehensive, may over-segment)
|
||||||
|
# - intersection: Only cuts from all 3 detectors (highest confidence)
|
||||||
|
# - majority: Cuts from 2+ detectors
|
||||||
|
SCENE_MERGE_STRATEGY=weighted
|
||||||
|
|
||||||
|
# Tolerance window for grouping cuts (seconds)
|
||||||
|
# Cuts within this window are considered "the same" cut
|
||||||
|
SCENE_MERGE_TOLERANCE=0.75
|
||||||
|
|
||||||
|
# Run GPU detectors in parallel (requires >12GB VRAM)
|
||||||
|
# When false (default), runs sequentially: PyScene -> TransNet -> CLIP
|
||||||
|
SCENE_DETECTION_PARALLEL=false
|
||||||
|
|
||||||
|
# Detector weights for weighted merge strategy
|
||||||
|
# Higher weight = more trusted detector
|
||||||
|
SCENE_WEIGHT_TRANSNET=1.0 # Neural network, best for soft transitions
|
||||||
|
SCENE_WEIGHT_PYSCENEDETECT=0.8 # Histogram-based, fast, good for hard cuts
|
||||||
|
SCENE_WEIGHT_CLIP=0.9 # Visual embeddings, semantic/content changes
|
||||||
|
|
||||||
|
# CLIP boundary detection configuration
|
||||||
|
# Extracts frames at interval, computes embeddings, detects similarity drops
|
||||||
|
CLIP_BOUNDARY_FRAME_INTERVAL=0.5 # Seconds between frames (0.5 = 2 fps)
|
||||||
|
CLIP_BOUNDARY_SIMILARITY_THRESHOLD=0.7 # Below this = scene boundary (0-1)
|
||||||
|
CLIP_BOUNDARY_MIN_GAP=2.0 # Minimum seconds between boundaries
|
||||||
|
|
||||||
|
# CLIP embeddings GPU batch size (default: 128)
|
||||||
|
# Higher = faster processing, more VRAM usage
|
||||||
|
# RTX 4050 6GB: safe up to 512, recommended 256
|
||||||
|
# RTX 3090 24GB: can use 1024+
|
||||||
|
CLIP_GPU_BATCH_SIZE=256
|
||||||
|
|
||||||
|
# Database path (inside container)
|
||||||
|
DATABASE_URL=/app/data/library.db
|
||||||
|
|
||||||
|
# Media library paths (host paths)
|
||||||
|
MEDIA_ROOT=/media/bunker-admin/Internal/plex/xxx/media
|
||||||
|
MEDIA_LOCAL=/media/bunker-admin/Internal/plex/xxx/media/local
|
||||||
|
MEDIA_PUBLIC=/media/bunker-admin/Internal/plex/xxx/media/public
|
||||||
|
STUDIOS_PATH=/media/bunker-admin/Internal/plex/xxx/media/local/studios
|
||||||
|
GIFS_PATH=/media/bunker-admin/Internal/plex/xxx/media/local/gifs
|
||||||
|
PLAYBACK_PATH=/media/bunker-admin/Internal/plex/xxx/media/public/playback
|
||||||
|
COMPILATIONS_PATH=/media/bunker-admin/Internal/plex/xxx/media/public/compilations
|
||||||
|
PUBLIC_CURATED_PATH=/media/bunker-admin/Internal/plex/xxx/media/public/curated
|
||||||
|
QUICKIES_PATH=/media/bunker-admin/Internal/plex/xxx/media/public/quickies
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PAYMENT SYSTEM CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
# IMAP settings for automatic e-transfer email parsing
|
||||||
|
# Leave empty to disable automatic payment polling (manual entry only)
|
||||||
|
#
|
||||||
|
# For Proton Mail: Requires Proton Mail Bridge running locally
|
||||||
|
# PAYMENT_IMAP_HOST=127.0.0.1 (or host.docker.internal from container)
|
||||||
|
# PAYMENT_IMAP_PORT=1143
|
||||||
|
# PAYMENT_IMAP_TLS=false (Bridge uses STARTTLS)
|
||||||
|
#
|
||||||
|
# For Gmail: Enable "Less secure app access" or use App Password
|
||||||
|
# PAYMENT_IMAP_HOST=imap.gmail.com
|
||||||
|
# PAYMENT_IMAP_PORT=993
|
||||||
|
# PAYMENT_IMAP_TLS=true
|
||||||
|
#
|
||||||
|
PAYMENT_IMAP_HOST=
|
||||||
|
PAYMENT_IMAP_PORT=993
|
||||||
|
PAYMENT_IMAP_USER=
|
||||||
|
PAYMENT_IMAP_PASS=
|
||||||
|
PAYMENT_IMAP_TLS=true
|
||||||
|
|
||||||
|
# How often to check for new payment emails (milliseconds)
|
||||||
|
# Default: 60000 (1 minute)
|
||||||
|
PAYMENT_POLL_INTERVAL_MS=60000
|
||||||
|
|
||||||
|
# Folder to move processed emails to (created automatically)
|
||||||
|
PAYMENT_PROCESSED_FOLDER=Processed
|
||||||
Loading…
x
Reference in New Issue
Block a user