initial commit
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,232 @@
|
|||||||
|
from flask import Flask, render_template, request, jsonify, send_file, send_from_directory
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
|
||||||
|
from database import init_db, add_project, get_all_projects, get_project, update_last_capture, delete_project
|
||||||
|
from scheduler import scheduler, start_job, stop_job, start_all_jobs
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
CAPTURES_DIR = Path('captures')
|
||||||
|
TIMELAPSES_DIR = Path('timelapses')
|
||||||
|
|
||||||
|
# Ensure directories exist
|
||||||
|
CAPTURES_DIR.mkdir(exist_ok=True)
|
||||||
|
TIMELAPSES_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/api/projects', methods=['GET'])
|
||||||
|
def list_projects():
|
||||||
|
projects = get_all_projects()
|
||||||
|
result = []
|
||||||
|
for p in projects:
|
||||||
|
project_id, name, ip, port, period, username, password, created, last_capture, active = p
|
||||||
|
|
||||||
|
# Count images and get last image
|
||||||
|
safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
|
||||||
|
capture_folder = CAPTURES_DIR / safe_name
|
||||||
|
|
||||||
|
image_count = 0
|
||||||
|
last_image = None
|
||||||
|
|
||||||
|
if capture_folder.exists():
|
||||||
|
images = sorted(capture_folder.glob('*.jpg'))
|
||||||
|
image_count = len(images)
|
||||||
|
if images:
|
||||||
|
last_image = f"/api/projects/{project_id}/image/latest"
|
||||||
|
|
||||||
|
# Calculate runtime
|
||||||
|
created_dt = datetime.strptime(created, '%Y-%m-%d %H:%M:%S')
|
||||||
|
runtime = datetime.now() - created_dt
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
'id': project_id,
|
||||||
|
'name': name,
|
||||||
|
'ip_address': ip,
|
||||||
|
'port': port,
|
||||||
|
'period_minutes': period,
|
||||||
|
'created': created,
|
||||||
|
'runtime_hours': round(runtime.total_seconds() / 3600, 2),
|
||||||
|
'image_count': image_count,
|
||||||
|
'last_capture': last_capture,
|
||||||
|
'has_auth': bool(username and password),
|
||||||
|
'last_image': last_image
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
@app.route('/api/projects', methods=['POST'])
|
||||||
|
def create_project():
|
||||||
|
data = request.json
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
ip_address = data.get('ip_address', '').strip()
|
||||||
|
port = data.get('port')
|
||||||
|
period_minutes = data.get('period_minutes')
|
||||||
|
username = data.get('username', '').strip() or None
|
||||||
|
password = data.get('password', '').strip() or None
|
||||||
|
|
||||||
|
# Basic validation
|
||||||
|
if not name or not ip_address or not port or not period_minutes:
|
||||||
|
return jsonify({'error': 'All required fields must be filled'}), 400
|
||||||
|
|
||||||
|
# Validate IP address format
|
||||||
|
ip_pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
|
||||||
|
if not re.match(ip_pattern, ip_address):
|
||||||
|
return jsonify({'error': 'Invalid IP address format'}), 400
|
||||||
|
|
||||||
|
# Validate port
|
||||||
|
if not (1 <= port <= 65535):
|
||||||
|
return jsonify({'error': 'Port must be between 1 and 65535'}), 400
|
||||||
|
|
||||||
|
# Create project in database
|
||||||
|
project_id = add_project(name, ip_address, port, period_minutes, username, password)
|
||||||
|
if not project_id:
|
||||||
|
return jsonify({'error': 'Project name already exists'}), 400
|
||||||
|
|
||||||
|
# Start scheduler job
|
||||||
|
start_job(project_id, ip_address, port, period_minutes, username, password, name)
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'project_id': project_id})
|
||||||
|
|
||||||
|
@app.route('/api/projects/<int:project_id>', methods=['DELETE'])
|
||||||
|
def remove_project(project_id):
|
||||||
|
project = get_project(project_id)
|
||||||
|
if not project:
|
||||||
|
return jsonify({'error': 'Project not found'}), 404
|
||||||
|
|
||||||
|
stop_job(project_id)
|
||||||
|
delete_project(project_id)
|
||||||
|
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
@app.route('/api/projects/<int:project_id>/image/latest')
|
||||||
|
def get_latest_image(project_id):
|
||||||
|
"""Serve the latest captured image"""
|
||||||
|
project = get_project(project_id)
|
||||||
|
if not project:
|
||||||
|
return jsonify({'error': 'Project not found'}), 404
|
||||||
|
|
||||||
|
_, name, _, _, _, _, _, _, _, _ = project
|
||||||
|
safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
|
||||||
|
capture_folder = CAPTURES_DIR / safe_name
|
||||||
|
|
||||||
|
if not capture_folder.exists():
|
||||||
|
return jsonify({'error': 'No images found'}), 404
|
||||||
|
|
||||||
|
images = sorted(capture_folder.glob('*.jpg'))
|
||||||
|
if not images:
|
||||||
|
return jsonify({'error': 'No images found'}), 404
|
||||||
|
|
||||||
|
return send_file(images[-1], mimetype='image/jpeg')
|
||||||
|
|
||||||
|
@app.route('/api/projects/<int:project_id>/gallery')
|
||||||
|
def get_gallery(project_id):
|
||||||
|
"""Get list of images for gallery (last 50)"""
|
||||||
|
project = get_project(project_id)
|
||||||
|
if not project:
|
||||||
|
return jsonify({'error': 'Project not found'}), 404
|
||||||
|
|
||||||
|
_, name, _, _, _, _, _, _, _, _ = project
|
||||||
|
safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
|
||||||
|
capture_folder = CAPTURES_DIR / safe_name
|
||||||
|
|
||||||
|
if not capture_folder.exists():
|
||||||
|
return jsonify({'error': 'No images found'}), 404
|
||||||
|
|
||||||
|
images = sorted(capture_folder.glob('*.jpg'), reverse=True)[:50]
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for img in images:
|
||||||
|
stat = img.stat()
|
||||||
|
result.append({
|
||||||
|
'filename': img.name,
|
||||||
|
'path': f"/api/projects/{project_id}/image/{img.name}",
|
||||||
|
'size': stat.st_size,
|
||||||
|
'created': datetime.fromtimestamp(stat.st_ctime).isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
@app.route('/api/projects/<int:project_id>/image/<filename>')
|
||||||
|
def get_image(project_id, filename):
|
||||||
|
"""Serve a specific image"""
|
||||||
|
project = get_project(project_id)
|
||||||
|
if not project:
|
||||||
|
return jsonify({'error': 'Project not found'}), 404
|
||||||
|
|
||||||
|
_, name, _, _, _, _, _, _, _, _ = project
|
||||||
|
safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
|
||||||
|
capture_folder = CAPTURES_DIR / safe_name
|
||||||
|
|
||||||
|
return send_from_directory(str(capture_folder), filename, mimetype='image/jpeg')
|
||||||
|
|
||||||
|
@app.route('/api/projects/<int:project_id>/timelapse', methods=['POST'])
|
||||||
|
def create_timelapse(project_id):
|
||||||
|
project = get_project(project_id)
|
||||||
|
if not project:
|
||||||
|
return jsonify({'error': 'Project not found'}), 404
|
||||||
|
|
||||||
|
_, name, _, _, _, _, _, _, _, _ = project
|
||||||
|
safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
|
||||||
|
capture_folder = CAPTURES_DIR / safe_name
|
||||||
|
|
||||||
|
if not capture_folder.exists():
|
||||||
|
return jsonify({'error': 'No captures found'}), 404
|
||||||
|
|
||||||
|
# Get all images sorted by name
|
||||||
|
images = sorted(capture_folder.glob('*.jpg'))
|
||||||
|
if len(images) < 2:
|
||||||
|
return jsonify({'error': 'Need at least 2 images to create timelapse'}), 400
|
||||||
|
|
||||||
|
# Create video filename
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
video_filename = f"{safe_name}_{timestamp}.mp4"
|
||||||
|
video_path = TIMELAPSES_DIR / video_filename
|
||||||
|
|
||||||
|
# Create image list file for ffmpeg
|
||||||
|
list_file = capture_folder / 'images.txt'
|
||||||
|
with open(list_file, 'w') as f:
|
||||||
|
for img in images:
|
||||||
|
f.write(f"file '{img.absolute()}'\n")
|
||||||
|
|
||||||
|
# Run ffmpeg to create timelapse
|
||||||
|
ffmpeg_cmd = [
|
||||||
|
'ffmpeg', '-y',
|
||||||
|
'-f', 'concat',
|
||||||
|
'-safe', '0',
|
||||||
|
'-i', str(list_file),
|
||||||
|
'-vf', 'fps=30',
|
||||||
|
'-c:v', 'libx264',
|
||||||
|
'-pix_fmt', 'yuv420p',
|
||||||
|
str(video_path)
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return jsonify({'error': f'ffmpeg failed: {result.stderr}'}), 500
|
||||||
|
|
||||||
|
# Send file for download
|
||||||
|
return send_file(
|
||||||
|
video_path,
|
||||||
|
mimetype='video/mp4',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=video_filename
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# Start scheduler
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
# Load existing projects and restart their jobs
|
||||||
|
projects = get_all_projects()
|
||||||
|
start_all_jobs(projects)
|
||||||
|
|
||||||
|
app.run(host='0.0.0.0', port=5800, debug=True, use_reloader=False)
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
DB_PATH = 'timelapse.db'
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
ip_address TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
period_minutes INTEGER NOT NULL,
|
||||||
|
username TEXT,
|
||||||
|
password TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_capture TIMESTAMP,
|
||||||
|
active INTEGER DEFAULT 1
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def add_project(name, ip_address, port, period_minutes, username=None, password=None):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
try:
|
||||||
|
c.execute('''
|
||||||
|
INSERT INTO projects (name, ip_address, port, period_minutes, username, password)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
''', (name, ip_address, port, period_minutes, username, password))
|
||||||
|
conn.commit()
|
||||||
|
return c.lastrowid
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_all_projects():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('SELECT * FROM projects WHERE active = 1')
|
||||||
|
projects = c.fetchall()
|
||||||
|
conn.close()
|
||||||
|
return projects
|
||||||
|
|
||||||
|
def get_project(project_id):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('SELECT * FROM projects WHERE id = ?', (project_id,))
|
||||||
|
project = c.fetchone()
|
||||||
|
conn.close()
|
||||||
|
return project
|
||||||
|
|
||||||
|
def update_last_capture(project_id):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('''
|
||||||
|
UPDATE projects SET last_capture = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
''', (project_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def delete_project(project_id):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('UPDATE projects SET active = 0 WHERE id = ?', (project_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
flask==3.0.0
|
||||||
|
apscheduler==3.10.4
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
import requests
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
import re
|
||||||
|
|
||||||
|
scheduler = BackgroundScheduler()
|
||||||
|
jobs = {}
|
||||||
|
|
||||||
|
def capture_image(project_id, ip_address, port, username, password, capture_folder):
|
||||||
|
"""Capture an image from the camera"""
|
||||||
|
try:
|
||||||
|
# Build URL with optional auth
|
||||||
|
if username and password:
|
||||||
|
url = f"http://{username}:{password}@{ip_address}:{port}/axis-cgi/jpg/image.cgi"
|
||||||
|
else:
|
||||||
|
url = f"http://{ip_address}:{port}/axis-cgi/jpg/image.cgi"
|
||||||
|
|
||||||
|
# Create timestamped filename
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', capture_folder)
|
||||||
|
folder_path = Path(f"captures/{safe_name}")
|
||||||
|
folder_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
output_path = folder_path / f"{timestamp}.jpg"
|
||||||
|
|
||||||
|
# Fetch image
|
||||||
|
response = requests.get(url, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Save image
|
||||||
|
with open(output_path, 'wb') as f:
|
||||||
|
f.write(response.content)
|
||||||
|
|
||||||
|
print(f"Captured image for project {project_id}: {output_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to capture image for project {project_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def start_job(project_id, ip_address, port, period_minutes, username, password, capture_folder):
|
||||||
|
"""Start a scheduled capture job"""
|
||||||
|
# Remove existing job if any
|
||||||
|
stop_job(project_id)
|
||||||
|
|
||||||
|
# Add new job
|
||||||
|
job = scheduler.add_job(
|
||||||
|
capture_image,
|
||||||
|
trigger=IntervalTrigger(minutes=period_minutes),
|
||||||
|
id=f"project_{project_id}",
|
||||||
|
args=[project_id, ip_address, port, username, password, capture_folder],
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
jobs[project_id] = job
|
||||||
|
print(f"Started job for project {project_id} (every {period_minutes} minutes)")
|
||||||
|
|
||||||
|
def stop_job(project_id):
|
||||||
|
"""Stop a scheduled capture job"""
|
||||||
|
job_id = f"project_{project_id}"
|
||||||
|
if scheduler.get_job(job_id):
|
||||||
|
scheduler.remove_job(job_id)
|
||||||
|
print(f"Stopped job for project {project_id}")
|
||||||
|
if project_id in jobs:
|
||||||
|
del jobs[project_id]
|
||||||
|
|
||||||
|
def start_all_jobs(projects):
|
||||||
|
"""Start jobs for all active projects"""
|
||||||
|
for p in projects:
|
||||||
|
project_id, name, ip, port, period, username, password, created, last_capture, active = p
|
||||||
|
start_job(project_id, ip, port, period, username, password, name)
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Timelapse Camera Manager</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
h1 { margin-bottom: 20px; color: #333; }
|
||||||
|
|
||||||
|
.form-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 20px; }
|
||||||
|
.form-row { display: flex; gap: 15px; flex-wrap: wrap; align-items: flex-end; }
|
||||||
|
.form-group { flex: 1; min-width: 120px; }
|
||||||
|
.form-group label { display: block; margin-bottom: 5px; font-weight: 500; color: #555; }
|
||||||
|
.form-group input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
||||||
|
.btn { padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 500; }
|
||||||
|
.btn-primary { background: #007bff; color: white; }
|
||||||
|
.btn-primary:hover { background: #0056b3; }
|
||||||
|
.btn-danger { background: #dc3545; color: white; }
|
||||||
|
.btn-danger:hover { background: #c82333; }
|
||||||
|
.btn-success { background: #28a745; color: white; }
|
||||||
|
.btn-success:hover { background: #218838; }
|
||||||
|
.btn-secondary { background: #6c757d; color: white; }
|
||||||
|
.btn-secondary:hover { background: #545b62; }
|
||||||
|
.btn-small { padding: 6px 12px; font-size: 12px; }
|
||||||
|
|
||||||
|
.table-card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); overflow: hidden; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { padding: 15px; text-align: left; border-bottom: 1px solid #eee; }
|
||||||
|
th { background: #f8f9fa; font-weight: 600; color: #555; }
|
||||||
|
tr:hover { background: #f8f9fa; }
|
||||||
|
|
||||||
|
.status-badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
|
||||||
|
.status-running { background: #d4edda; color: #155724; }
|
||||||
|
|
||||||
|
.empty-state { text-align: center; padding: 40px; color: #888; }
|
||||||
|
|
||||||
|
#message { padding: 15px; border-radius: 4px; margin-bottom: 20px; display: none; }
|
||||||
|
.message-success { background: #d4edda; color: #155724; }
|
||||||
|
.message-error { background: #f8d7da; color: #721c24; }
|
||||||
|
|
||||||
|
.auth-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 11px; background: #e2e3e5; color: #383d41; margin-left: 5px; }
|
||||||
|
|
||||||
|
.last-image-thumb { width: 80px; height: 60px; object-fit: cover; border-radius: 4px; border: 1px solid #ddd; background: #f0f0f0; }
|
||||||
|
.no-image { width: 80px; height: 60px; display: flex; align-items: center; justify-content: center; background: #f0f0f0; border-radius: 4px; color: #999; font-size: 11px; }
|
||||||
|
|
||||||
|
/* Gallery Modal */
|
||||||
|
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9); z-index: 1000; }
|
||||||
|
.modal-content { max-width: 90%; max-height: 90%; margin: 2% auto; position: relative; }
|
||||||
|
.modal-img { max-width: 100%; max-height: 85vh; display: block; margin: 0 auto; }
|
||||||
|
.modal-close { position: absolute; top: 20px; right: 30px; color: white; font-size: 40px; cursor: pointer; z-index: 1001; }
|
||||||
|
.modal-prev, .modal-next { position: absolute; top: 50%; transform: translateY(-50%); color: white; font-size: 40px; cursor: pointer; padding: 20px; user-select: none; }
|
||||||
|
.modal-prev { left: 20px; }
|
||||||
|
.modal-next { right: 20px; }
|
||||||
|
.modal-caption { text-align: center; color: white; padding: 10px; font-size: 14px; }
|
||||||
|
|
||||||
|
/* Gallery Grid */
|
||||||
|
.gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; padding: 20px; }
|
||||||
|
.gallery-item { cursor: pointer; position: relative; aspect-ratio: 4/3; overflow: hidden; border-radius: 4px; background: #f0f0f0; }
|
||||||
|
.gallery-item img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.2s; }
|
||||||
|
.gallery-item:hover img { transform: scale(1.05); }
|
||||||
|
.gallery-item .overlay { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.7); color: white; padding: 5px; font-size: 11px; }
|
||||||
|
|
||||||
|
.gallery-container { display: none; background: white; border-radius: 8px; margin-top: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
.gallery-header { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; border-bottom: 1px solid #eee; }
|
||||||
|
.gallery-header h3 { margin: 0; }
|
||||||
|
.loading { text-align: center; padding: 40px; color: #888; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>📷 Timelapse Camera Manager</h1>
|
||||||
|
|
||||||
|
<div id="message"></div>
|
||||||
|
|
||||||
|
<div class="form-card">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Project Name *</label>
|
||||||
|
<input type="text" id="projectName" placeholder="My Camera">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>IP Address *</label>
|
||||||
|
<input type="text" id="ipAddress" placeholder="192.168.1.100">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Port *</label>
|
||||||
|
<input type="number" id="port" placeholder="80" min="1" max="65535">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Period (min) *</label>
|
||||||
|
<input type="number" id="period" placeholder="5" min="1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Username</label>
|
||||||
|
<input type="text" id="username" placeholder="root">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" id="password" placeholder="pass">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="createProject()">Add Project</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Camera</th>
|
||||||
|
<th>Period</th>
|
||||||
|
<th>Runtime</th>
|
||||||
|
<th>Last Image</th>
|
||||||
|
<th>Images</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="projectsTable">
|
||||||
|
<tr><td colspan="7" class="empty-state">No projects yet. Add one above!</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gallery Container -->
|
||||||
|
<div id="galleryContainer" class="gallery-container">
|
||||||
|
<div class="gallery-header">
|
||||||
|
<h3 id="galleryTitle">Gallery</h3>
|
||||||
|
<button class="btn btn-secondary btn-small" onclick="closeGallery()">Close Gallery</button>
|
||||||
|
</div>
|
||||||
|
<div id="galleryGrid" class="gallery-grid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Modal -->
|
||||||
|
<div id="imageModal" class="modal">
|
||||||
|
<span class="modal-close" onclick="closeModal()">×</span>
|
||||||
|
<span class="modal-prev" onclick="prevImage()">❮</span>
|
||||||
|
<span class="modal-next" onclick="nextImage()">❯</span>
|
||||||
|
<img class="modal-img" id="modalImg">
|
||||||
|
<div class="modal-caption" id="modalCaption"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentGallery = [];
|
||||||
|
let currentIndex = 0;
|
||||||
|
let currentProjectName = '';
|
||||||
|
|
||||||
|
function showMessage(msg, type) {
|
||||||
|
const el = document.getElementById('message');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = type === 'success' ? 'message-success' : 'message-error';
|
||||||
|
el.style.display = 'block';
|
||||||
|
setTimeout(() => el.style.display = 'none', 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
const res = await fetch('/api/projects');
|
||||||
|
const projects = await res.json();
|
||||||
|
const tbody = document.getElementById('projectsTable');
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">No projects yet. Add one above!</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = projects.map(p => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>${p.name}</strong>
|
||||||
|
${p.has_auth ? '<span class="auth-badge">🔒 Auth</span>' : ''}
|
||||||
|
</td>
|
||||||
|
<td>${p.ip_address}:${p.port}</td>
|
||||||
|
<td>${p.period_minutes} min</td>
|
||||||
|
<td><span class="status-badge status-running">${p.runtime_hours} hrs</span></td>
|
||||||
|
<td>
|
||||||
|
${p.last_image
|
||||||
|
? `<img src="${p.last_image}" class="last-image-thumb" alt="Latest" onload="this.style.background='none'">`
|
||||||
|
: `<div class="no-image">No images</div>`
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>${p.image_count}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-success btn-small" onclick="createTimelapse(${p.id})">Timelapse</button>
|
||||||
|
<button class="btn btn-secondary btn-small" onclick="openGallery(${p.id}, '${p.name}')">Gallery</button>
|
||||||
|
<button class="btn btn-danger btn-small" onclick="deleteProject(${p.id})">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProject() {
|
||||||
|
const name = document.getElementById('projectName').value;
|
||||||
|
const ip_address = document.getElementById('ipAddress').value;
|
||||||
|
const port = parseInt(document.getElementById('port').value);
|
||||||
|
const period_minutes = parseInt(document.getElementById('period').value);
|
||||||
|
const username = document.getElementById('username').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
const res = await fetch('/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, ip_address, port, period_minutes, username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
showMessage('Project created successfully!', 'success');
|
||||||
|
document.getElementById('projectName').value = '';
|
||||||
|
document.getElementById('ipAddress').value = '';
|
||||||
|
document.getElementById('port').value = '';
|
||||||
|
document.getElementById('period').value = '';
|
||||||
|
document.getElementById('username').value = '';
|
||||||
|
document.getElementById('password').value = '';
|
||||||
|
loadProjects();
|
||||||
|
} else {
|
||||||
|
showMessage(data.error || 'Failed to create project', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProject(id) {
|
||||||
|
if (!confirm('Are you sure? This will stop captures and delete the project.')) return;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
showMessage('Project deleted', 'success');
|
||||||
|
loadProjects();
|
||||||
|
} else {
|
||||||
|
showMessage('Failed to delete project', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTimelapse(id) {
|
||||||
|
showMessage('Creating timelapse... this may take a moment', 'success');
|
||||||
|
|
||||||
|
const res = await fetch(`/api/projects/${id}/timelapse`, { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `timelapse_${id}.mp4`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
showMessage('Timelapse downloaded!', 'success');
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
showMessage(data.error || 'Failed to create timelapse', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openGallery(projectId, projectName) {
|
||||||
|
currentProjectName = projectName;
|
||||||
|
document.getElementById('galleryTitle').textContent = `Gallery: ${projectName}`;
|
||||||
|
document.getElementById('galleryContainer').style.display = 'block';
|
||||||
|
document.getElementById('galleryGrid').innerHTML = '<div class="loading">Loading images...</div>';
|
||||||
|
|
||||||
|
// Scroll to gallery
|
||||||
|
document.getElementById('galleryContainer').scrollIntoView({ behavior: 'smooth' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/gallery`);
|
||||||
|
const images = await res.json();
|
||||||
|
|
||||||
|
if (images.error) {
|
||||||
|
document.getElementById('galleryGrid').innerHTML = `<div class="loading">${images.error}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentGallery = images;
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
document.getElementById('galleryGrid').innerHTML = '<div class="loading">No images yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy load gallery grid
|
||||||
|
document.getElementById('galleryGrid').innerHTML = images.map((img, idx) => `
|
||||||
|
<div class="gallery-item" onclick="showModal(${idx})">
|
||||||
|
<img data-src="${img.path}" alt="${img.filename}" loading="lazy">
|
||||||
|
<div class="overlay">${img.created.split('T')[0]}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Lazy load images in grid
|
||||||
|
document.querySelectorAll('.gallery-item img').forEach(img => {
|
||||||
|
img.src = img.dataset.src;
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('galleryGrid').innerHTML = '<div class="loading">Failed to load gallery</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGallery() {
|
||||||
|
document.getElementById('galleryContainer').style.display = 'none';
|
||||||
|
currentGallery = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function showModal(index) {
|
||||||
|
if (currentGallery.length === 0) return;
|
||||||
|
currentIndex = index;
|
||||||
|
const img = currentGallery[index];
|
||||||
|
document.getElementById('modalImg').src = img.path;
|
||||||
|
document.getElementById('modalCaption').textContent = `${img.filename} - ${img.created.replace('T', ' ').split('.')[0]}`;
|
||||||
|
document.getElementById('imageModal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('imageModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevImage() {
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
showModal(currentIndex - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextImage() {
|
||||||
|
if (currentIndex < currentGallery.length - 1) {
|
||||||
|
showModal(currentIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (document.getElementById('imageModal').style.display === 'block') {
|
||||||
|
if (e.key === 'Escape') closeModal();
|
||||||
|
if (e.key === 'ArrowLeft') prevImage();
|
||||||
|
if (e.key === 'ArrowRight') nextImage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load projects on page load
|
||||||
|
loadProjects();
|
||||||
|
// Refresh every 30 seconds
|
||||||
|
setInterval(loadProjects, 30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user