Refactor Nginx configuration and update project dependencies
- Cleaned up the Nginx configuration for the Coolify deployment, ensuring proper formatting and organization. - Updated the `pyproject.toml` file to maintain consistent formatting. - Reformatted the `requirements.txt` file for better readability while keeping the dependencies unchanged.
This commit is contained in:
1 parent
3b8c30c289
commit
a4b874598d
88 files changed
+9766
-9766
No files matched your search
+21
-21
@@ -1,21 +1,21 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 12016
|
||||
|
||||
# Run the application
|
||||
CMD ["gunicorn", "app.main:app", "--bind", "0.0.0.0:12016", "--workers", "2", "--timeout", "120"]
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 12016
|
||||
|
||||
# Run the application
|
||||
CMD ["gunicorn", "app.main:app", "--bind", "0.0.0.0:12016", "--workers", "2", "--timeout", "120"]
|
||||
+57
-57
@@ -1,57 +1,57 @@
|
||||
# Frontend — Flask Web UI
|
||||
|
||||
Server-rendered UI powered by Flask + Jinja2 + Tailwind CSS.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Choice |
|
||||
| ----------- | -------------------------------- |
|
||||
| Framework | Flask |
|
||||
| Templates | Jinja2 |
|
||||
| Styling | Tailwind CSS (CDN) + custom CSS |
|
||||
| HTTP client | httpx |
|
||||
| Server | gunicorn (prod), Flask dev (dev) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```txt
|
||||
frontend/
|
||||
app/
|
||||
__init__.py App factory (create_app)
|
||||
main.py Entry point — calls create_app()
|
||||
config.py Configuration (secret key, backend URL)
|
||||
filters.py Jinja2 template filters (fromisoformat, humantime)
|
||||
helpers.py API client (_api), auth decorators, model helpers
|
||||
routes/ Blueprint route handlers
|
||||
__init__.py register_blueprints()
|
||||
auth.py /login, /register, /logout, /
|
||||
dashboard.py /dashboard
|
||||
gallery.py /gallery, image/video/upload detail, /images/<id>/file
|
||||
generate.py /generate/text, /generate/image, /generate/video
|
||||
admin.py /admin, /admin/models, /api/admin/*
|
||||
profile.py /users/profile
|
||||
templates/ Jinja2 HTML templates
|
||||
admin/ Admin sub-templates (models, videos)
|
||||
static/ CSS (style.css), JS (app.js)
|
||||
tests/ pytest suite
|
||||
Dockerfile Production container
|
||||
requirements.txt Runtime dependencies
|
||||
requirements-dev.txt Dev dependencies
|
||||
```
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
flask --app app.main run --port 12016 --debug
|
||||
```
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
pytest frontend/tests/
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
See [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md) for full documentation (arc42 template).
|
||||
# Frontend — Flask Web UI
|
||||
|
||||
Server-rendered UI powered by Flask + Jinja2 + Tailwind CSS.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Choice |
|
||||
| ----------- | -------------------------------- |
|
||||
| Framework | Flask |
|
||||
| Templates | Jinja2 |
|
||||
| Styling | Tailwind CSS (CDN) + custom CSS |
|
||||
| HTTP client | httpx |
|
||||
| Server | gunicorn (prod), Flask dev (dev) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```txt
|
||||
frontend/
|
||||
app/
|
||||
__init__.py App factory (create_app)
|
||||
main.py Entry point — calls create_app()
|
||||
config.py Configuration (secret key, backend URL)
|
||||
filters.py Jinja2 template filters (fromisoformat, humantime)
|
||||
helpers.py API client (_api), auth decorators, model helpers
|
||||
routes/ Blueprint route handlers
|
||||
__init__.py register_blueprints()
|
||||
auth.py /login, /register, /logout, /
|
||||
dashboard.py /dashboard
|
||||
gallery.py /gallery, image/video/upload detail, /images/<id>/file
|
||||
generate.py /generate/text, /generate/image, /generate/video
|
||||
admin.py /admin, /admin/models, /api/admin/*
|
||||
profile.py /users/profile
|
||||
templates/ Jinja2 HTML templates
|
||||
admin/ Admin sub-templates (models, videos)
|
||||
static/ CSS (style.css), JS (app.js)
|
||||
tests/ pytest suite
|
||||
Dockerfile Production container
|
||||
requirements.txt Runtime dependencies
|
||||
requirements-dev.txt Dev dependencies
|
||||
```
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
flask --app app.main run --port 12016 --debug
|
||||
```
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
pytest frontend/tests/
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
See [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md) for full documentation (arc42 template).
|
||||
+16
-16
@@ -1,17 +1,17 @@
|
||||
"""Flask frontend application factory."""
|
||||
from flask import Flask
|
||||
|
||||
from .config import Config
|
||||
from .filters import register_filters
|
||||
from .routes import register_blueprints
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""Create and configure the Flask application."""
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
register_filters(app)
|
||||
register_blueprints(app)
|
||||
|
||||
"""Flask frontend application factory."""
|
||||
from flask import Flask
|
||||
|
||||
from .config import Config
|
||||
from .filters import register_filters
|
||||
from .routes import register_blueprints
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""Create and configure the Flask application."""
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
register_filters(app)
|
||||
register_blueprints(app)
|
||||
|
||||
return app
|
||||
+10
-10
@@ -1,10 +1,10 @@
|
||||
"""Flask frontend configuration."""
|
||||
import os
|
||||
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.getenv(
|
||||
"FLASK_SECRET_KEY", "dev-secret-change-in-production")
|
||||
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:12015")
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
"""Flask frontend configuration."""
|
||||
import os
|
||||
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.getenv(
|
||||
"FLASK_SECRET_KEY", "dev-secret-change-in-production")
|
||||
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:12015")
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
+41
-41
@@ -1,41 +1,41 @@
|
||||
"""Jinja template filters for the frontend app."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def from_iso_format(s: str) -> datetime:
|
||||
"""Convert ISO 8601 string to datetime object."""
|
||||
return datetime.fromisoformat(s)
|
||||
|
||||
|
||||
def human_time(dt: datetime) -> str:
|
||||
"""Format a datetime object into a human-readable relative time."""
|
||||
now = datetime.now(timezone.utc)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
diff = now - dt
|
||||
seconds = diff.total_seconds()
|
||||
|
||||
if seconds < 60:
|
||||
return "just now"
|
||||
elif seconds < 3600:
|
||||
minutes = int(seconds / 60)
|
||||
return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
|
||||
elif seconds < 86400:
|
||||
hours = int(seconds / 3600)
|
||||
return f"{hours} hour{'s' if hours > 1 else ''} ago"
|
||||
elif seconds < 2592000:
|
||||
days = int(seconds / 86400)
|
||||
return f"{days} day{'s' if days > 1 else ''} ago"
|
||||
elif seconds < 31536000:
|
||||
months = int(seconds / 2592000)
|
||||
return f"{months} month{'s' if months > 1 else ''} ago"
|
||||
else:
|
||||
years = int(seconds / 31536000)
|
||||
return f"{years} year{'s' if years > 1 else ''} ago"
|
||||
|
||||
|
||||
def register_filters(app):
|
||||
"""Register all template filters on the Flask app."""
|
||||
app.template_filter("fromisoformat")(from_iso_format)
|
||||
app.template_filter("humantime")(human_time)
|
||||
"""Jinja template filters for the frontend app."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def from_iso_format(s: str) -> datetime:
|
||||
"""Convert ISO 8601 string to datetime object."""
|
||||
return datetime.fromisoformat(s)
|
||||
|
||||
|
||||
def human_time(dt: datetime) -> str:
|
||||
"""Format a datetime object into a human-readable relative time."""
|
||||
now = datetime.now(timezone.utc)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
diff = now - dt
|
||||
seconds = diff.total_seconds()
|
||||
|
||||
if seconds < 60:
|
||||
return "just now"
|
||||
elif seconds < 3600:
|
||||
minutes = int(seconds / 60)
|
||||
return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
|
||||
elif seconds < 86400:
|
||||
hours = int(seconds / 3600)
|
||||
return f"{hours} hour{'s' if hours > 1 else ''} ago"
|
||||
elif seconds < 2592000:
|
||||
days = int(seconds / 86400)
|
||||
return f"{days} day{'s' if days > 1 else ''} ago"
|
||||
elif seconds < 31536000:
|
||||
months = int(seconds / 2592000)
|
||||
return f"{months} month{'s' if months > 1 else ''} ago"
|
||||
else:
|
||||
years = int(seconds / 31536000)
|
||||
return f"{years} year{'s' if years > 1 else ''} ago"
|
||||
|
||||
|
||||
def register_filters(app):
|
||||
"""Register all template filters on the Flask app."""
|
||||
app.template_filter("fromisoformat")(from_iso_format)
|
||||
app.template_filter("humantime")(human_time)
|
||||
+149
-149
@@ -1,149 +1,149 @@
|
||||
"""Helper utilities for the frontend app."""
|
||||
import functools
|
||||
|
||||
import httpx
|
||||
from flask import redirect, session, url_for, flash
|
||||
|
||||
|
||||
def _backend(path: str) -> str:
|
||||
from flask import current_app
|
||||
return f"{current_app.config['BACKEND_URL']}{path}"
|
||||
|
||||
|
||||
def _api(method: str, path: str, *, token: str | None = None, **kwargs):
|
||||
headers = kwargs.pop("headers", {})
|
||||
timeout = kwargs.pop("timeout", 30)
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return httpx.request(method, _backend(path), headers=headers, timeout=timeout, **kwargs)
|
||||
|
||||
|
||||
def _model_matches_modality(model: dict, modality: str) -> bool:
|
||||
"""Heuristic fallback when backend modality filter returns empty."""
|
||||
model_modality = (model.get("modality") or "").lower()
|
||||
if model_modality == modality:
|
||||
return True
|
||||
|
||||
text = f"{model.get('id', '')} {model.get('name', '')}".lower()
|
||||
keywords = {
|
||||
"image": ["image", "dall-e", "flux", "stable-diffusion", "sdxl", "recraft", "ideogram", "gpt-image"],
|
||||
"video": ["video", "sora", "runway", "veo", "kling", "pika", "luma", "wan"],
|
||||
"audio": ["audio", "speech", "voice", "tts", "transcribe", "whisper"],
|
||||
}
|
||||
|
||||
if modality in keywords:
|
||||
return any(k in text for k in keywords[modality])
|
||||
|
||||
if modality == "text":
|
||||
non_text_hits = any(
|
||||
k in text for k in keywords["image"] + keywords["video"] + keywords["audio"])
|
||||
return not non_text_hits
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _load_models(token: str, modality: str) -> list[dict]:
|
||||
"""Load models for modality; fallback to unfiltered cache if needed."""
|
||||
try:
|
||||
models_resp = _api("GET", "/models/", token=token,
|
||||
params={"modality": modality})
|
||||
except httpx.RequestError:
|
||||
return []
|
||||
if models_resp.status_code == 200:
|
||||
try:
|
||||
models = models_resp.json()
|
||||
except ValueError:
|
||||
models = []
|
||||
if models:
|
||||
return models
|
||||
|
||||
try:
|
||||
all_resp = _api("GET", "/models/", token=token)
|
||||
except httpx.RequestError:
|
||||
return []
|
||||
if all_resp.status_code != 200:
|
||||
return []
|
||||
|
||||
try:
|
||||
all_models = all_resp.json()
|
||||
except ValueError:
|
||||
return []
|
||||
filtered = [m for m in all_models if _model_matches_modality(m, modality)]
|
||||
return filtered or all_models
|
||||
|
||||
|
||||
def login_required(view):
|
||||
@functools.wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if "access_token" not in session:
|
||||
return redirect(url_for("auth.login"))
|
||||
# Validate, with auto-refresh on expiry
|
||||
if not _validate_and_refresh():
|
||||
return redirect(url_for("auth.login"))
|
||||
return view(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
def admin_required(view):
|
||||
@functools.wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if "access_token" not in session:
|
||||
return redirect(url_for("auth.login"))
|
||||
if not _validate_and_refresh():
|
||||
return redirect(url_for("auth.login"))
|
||||
if session.get("user_role") != "admin":
|
||||
flash("Admin access required.", "error")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
return view(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
# ── Token validation & refresh ────────────────────────────────────────────
|
||||
|
||||
def _validate_access_token(token: str) -> bool:
|
||||
"""Return True if the access token is still valid."""
|
||||
try:
|
||||
resp = _api("GET", "/auth/validate", token=token)
|
||||
return resp.status_code == 200
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
|
||||
def _try_refresh() -> bool:
|
||||
"""Attempt to refresh an expired access token using the stored refresh token.
|
||||
|
||||
On success, updates session tokens in place. Returns True if a valid
|
||||
access token exists after the attempt.
|
||||
"""
|
||||
refresh_token = session.get("refresh_token")
|
||||
if not refresh_token:
|
||||
return False
|
||||
try:
|
||||
resp = _api("POST", "/auth/refresh",
|
||||
json={"refresh_token": refresh_token})
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
return False
|
||||
data = resp.json()
|
||||
session["access_token"] = data["access_token"]
|
||||
session["refresh_token"] = data["refresh_token"]
|
||||
return True
|
||||
|
||||
|
||||
def _validate_and_refresh() -> bool:
|
||||
"""Check access token validity; attempt refresh if expired.
|
||||
|
||||
Returns True if a valid session exists after the check.
|
||||
"""
|
||||
token = session.get("access_token")
|
||||
if not token:
|
||||
return False
|
||||
if _validate_access_token(token):
|
||||
return True
|
||||
# Access token expired — try to refresh
|
||||
if _try_refresh():
|
||||
return True
|
||||
# Both tokens are dead — clear session
|
||||
session.clear()
|
||||
return False
|
||||
"""Helper utilities for the frontend app."""
|
||||
import functools
|
||||
|
||||
import httpx
|
||||
from flask import redirect, session, url_for, flash
|
||||
|
||||
|
||||
def _backend(path: str) -> str:
|
||||
from flask import current_app
|
||||
return f"{current_app.config['BACKEND_URL']}{path}"
|
||||
|
||||
|
||||
def _api(method: str, path: str, *, token: str | None = None, **kwargs):
|
||||
headers = kwargs.pop("headers", {})
|
||||
timeout = kwargs.pop("timeout", 30)
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return httpx.request(method, _backend(path), headers=headers, timeout=timeout, **kwargs)
|
||||
|
||||
|
||||
def _model_matches_modality(model: dict, modality: str) -> bool:
|
||||
"""Heuristic fallback when backend modality filter returns empty."""
|
||||
model_modality = (model.get("modality") or "").lower()
|
||||
if model_modality == modality:
|
||||
return True
|
||||
|
||||
text = f"{model.get('id', '')} {model.get('name', '')}".lower()
|
||||
keywords = {
|
||||
"image": ["image", "dall-e", "flux", "stable-diffusion", "sdxl", "recraft", "ideogram", "gpt-image"],
|
||||
"video": ["video", "sora", "runway", "veo", "kling", "pika", "luma", "wan"],
|
||||
"audio": ["audio", "speech", "voice", "tts", "transcribe", "whisper"],
|
||||
}
|
||||
|
||||
if modality in keywords:
|
||||
return any(k in text for k in keywords[modality])
|
||||
|
||||
if modality == "text":
|
||||
non_text_hits = any(
|
||||
k in text for k in keywords["image"] + keywords["video"] + keywords["audio"])
|
||||
return not non_text_hits
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _load_models(token: str, modality: str) -> list[dict]:
|
||||
"""Load models for modality; fallback to unfiltered cache if needed."""
|
||||
try:
|
||||
models_resp = _api("GET", "/models/", token=token,
|
||||
params={"modality": modality})
|
||||
except httpx.RequestError:
|
||||
return []
|
||||
if models_resp.status_code == 200:
|
||||
try:
|
||||
models = models_resp.json()
|
||||
except ValueError:
|
||||
models = []
|
||||
if models:
|
||||
return models
|
||||
|
||||
try:
|
||||
all_resp = _api("GET", "/models/", token=token)
|
||||
except httpx.RequestError:
|
||||
return []
|
||||
if all_resp.status_code != 200:
|
||||
return []
|
||||
|
||||
try:
|
||||
all_models = all_resp.json()
|
||||
except ValueError:
|
||||
return []
|
||||
filtered = [m for m in all_models if _model_matches_modality(m, modality)]
|
||||
return filtered or all_models
|
||||
|
||||
|
||||
def login_required(view):
|
||||
@functools.wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if "access_token" not in session:
|
||||
return redirect(url_for("auth.login"))
|
||||
# Validate, with auto-refresh on expiry
|
||||
if not _validate_and_refresh():
|
||||
return redirect(url_for("auth.login"))
|
||||
return view(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
def admin_required(view):
|
||||
@functools.wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if "access_token" not in session:
|
||||
return redirect(url_for("auth.login"))
|
||||
if not _validate_and_refresh():
|
||||
return redirect(url_for("auth.login"))
|
||||
if session.get("user_role") != "admin":
|
||||
flash("Admin access required.", "error")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
return view(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
# ── Token validation & refresh ────────────────────────────────────────────
|
||||
|
||||
def _validate_access_token(token: str) -> bool:
|
||||
"""Return True if the access token is still valid."""
|
||||
try:
|
||||
resp = _api("GET", "/auth/validate", token=token)
|
||||
return resp.status_code == 200
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
|
||||
def _try_refresh() -> bool:
|
||||
"""Attempt to refresh an expired access token using the stored refresh token.
|
||||
|
||||
On success, updates session tokens in place. Returns True if a valid
|
||||
access token exists after the attempt.
|
||||
"""
|
||||
refresh_token = session.get("refresh_token")
|
||||
if not refresh_token:
|
||||
return False
|
||||
try:
|
||||
resp = _api("POST", "/auth/refresh",
|
||||
json={"refresh_token": refresh_token})
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
return False
|
||||
data = resp.json()
|
||||
session["access_token"] = data["access_token"]
|
||||
session["refresh_token"] = data["refresh_token"]
|
||||
return True
|
||||
|
||||
|
||||
def _validate_and_refresh() -> bool:
|
||||
"""Check access token validity; attempt refresh if expired.
|
||||
|
||||
Returns True if a valid session exists after the check.
|
||||
"""
|
||||
token = session.get("access_token")
|
||||
if not token:
|
||||
return False
|
||||
if _validate_access_token(token):
|
||||
return True
|
||||
# Access token expired — try to refresh
|
||||
if _try_refresh():
|
||||
return True
|
||||
# Both tokens are dead — clear session
|
||||
session.clear()
|
||||
return False
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Flask frontend application entry point."""
|
||||
from . import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=12016, debug=True)
|
||||
"""Flask frontend application entry point."""
|
||||
from . import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=12016, debug=True)
|
||||
@@ -1,19 +1,19 @@
|
||||
"""Blueprint registration."""
|
||||
from flask import Flask
|
||||
|
||||
|
||||
def register_blueprints(app: Flask):
|
||||
"""Register all application blueprints."""
|
||||
from .auth import auth_bp
|
||||
from .dashboard import dashboard_bp
|
||||
from .gallery import gallery_bp
|
||||
from .generate import generate_bp
|
||||
from .admin import admin_bp
|
||||
from .profile import profile_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(gallery_bp)
|
||||
app.register_blueprint(generate_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
"""Blueprint registration."""
|
||||
from flask import Flask
|
||||
|
||||
|
||||
def register_blueprints(app: Flask):
|
||||
"""Register all application blueprints."""
|
||||
from .auth import auth_bp
|
||||
from .dashboard import dashboard_bp
|
||||
from .gallery import gallery_bp
|
||||
from .generate import generate_bp
|
||||
from .admin import admin_bp
|
||||
from .profile import profile_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(gallery_bp)
|
||||
app.register_blueprint(generate_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(profile_bp)
|
||||
@@ -1,75 +1,75 @@
|
||||
"""Admin blueprint."""
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
|
||||
from ..helpers import _api, admin_required
|
||||
|
||||
admin_bp = Blueprint("admin", __name__)
|
||||
|
||||
|
||||
@admin_bp.get("/admin")
|
||||
@admin_required
|
||||
def index():
|
||||
token = session["access_token"]
|
||||
stats_resp = _api("GET", "/admin/stats", token=token)
|
||||
users_resp = _api("GET", "/users", token=token)
|
||||
stats = stats_resp.json() if stats_resp.status_code == 200 else {}
|
||||
users = users_resp.json() if users_resp.status_code == 200 else []
|
||||
return render_template("admin.html", stats=stats, users=users)
|
||||
|
||||
|
||||
@admin_bp.post("/admin/users/<user_id>/role")
|
||||
@admin_required
|
||||
def set_role(user_id: str):
|
||||
role = request.form.get("role", "user")
|
||||
_api("PUT", f"/users/{user_id}/role",
|
||||
token=session["access_token"], json={"role": role})
|
||||
flash(f"Role updated to '{role}'.", "success")
|
||||
return redirect(url_for("admin.index"))
|
||||
|
||||
|
||||
@admin_bp.post("/admin/users/<user_id>/delete")
|
||||
@admin_required
|
||||
def delete_user(user_id: str):
|
||||
_api("DELETE", f"/users/{user_id}", token=session["access_token"])
|
||||
flash("User deleted.", "success")
|
||||
return redirect(url_for("admin.index"))
|
||||
|
||||
|
||||
@admin_bp.get("/admin/models")
|
||||
@admin_required
|
||||
def models():
|
||||
"""Show model cache status and list all models."""
|
||||
return render_template("admin/models.html")
|
||||
|
||||
|
||||
# ── Admin API proxies (same-origin for browser JS) ────────────────────────
|
||||
|
||||
@admin_bp.get("/api/admin/videos")
|
||||
@admin_required
|
||||
def list_videos():
|
||||
resp = _api("GET", "/admin/videos", token=session["access_token"])
|
||||
return jsonify(resp.json()), resp.status_code
|
||||
|
||||
|
||||
@admin_bp.post("/api/admin/videos/<job_id>/retry")
|
||||
@admin_required
|
||||
def retry_video(job_id: str):
|
||||
resp = _api(
|
||||
"POST", f"/admin/videos/{job_id}/retry", token=session["access_token"])
|
||||
return jsonify(resp.json()), resp.status_code
|
||||
|
||||
|
||||
@admin_bp.post("/api/admin/videos/<job_id>/cancel")
|
||||
@admin_required
|
||||
def cancel_video(job_id: str):
|
||||
resp = _api(
|
||||
"POST", f"/admin/videos/{job_id}/cancel", token=session["access_token"])
|
||||
return jsonify(resp.json()), resp.status_code
|
||||
|
||||
|
||||
@admin_bp.delete("/api/admin/videos/<job_id>")
|
||||
@admin_required
|
||||
def delete_video(job_id: str):
|
||||
resp = _api(
|
||||
"DELETE", f"/admin/videos/{job_id}", token=session["access_token"])
|
||||
"""Admin blueprint."""
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
|
||||
from ..helpers import _api, admin_required
|
||||
|
||||
admin_bp = Blueprint("admin", __name__)
|
||||
|
||||
|
||||
@admin_bp.get("/admin")
|
||||
@admin_required
|
||||
def index():
|
||||
token = session["access_token"]
|
||||
stats_resp = _api("GET", "/admin/stats", token=token)
|
||||
users_resp = _api("GET", "/users", token=token)
|
||||
stats = stats_resp.json() if stats_resp.status_code == 200 else {}
|
||||
users = users_resp.json() if users_resp.status_code == 200 else []
|
||||
return render_template("admin.html", stats=stats, users=users)
|
||||
|
||||
|
||||
@admin_bp.post("/admin/users/<user_id>/role")
|
||||
@admin_required
|
||||
def set_role(user_id: str):
|
||||
role = request.form.get("role", "user")
|
||||
_api("PUT", f"/users/{user_id}/role",
|
||||
token=session["access_token"], json={"role": role})
|
||||
flash(f"Role updated to '{role}'.", "success")
|
||||
return redirect(url_for("admin.index"))
|
||||
|
||||
|
||||
@admin_bp.post("/admin/users/<user_id>/delete")
|
||||
@admin_required
|
||||
def delete_user(user_id: str):
|
||||
_api("DELETE", f"/users/{user_id}", token=session["access_token"])
|
||||
flash("User deleted.", "success")
|
||||
return redirect(url_for("admin.index"))
|
||||
|
||||
|
||||
@admin_bp.get("/admin/models")
|
||||
@admin_required
|
||||
def models():
|
||||
"""Show model cache status and list all models."""
|
||||
return render_template("admin/models.html")
|
||||
|
||||
|
||||
# ── Admin API proxies (same-origin for browser JS) ────────────────────────
|
||||
|
||||
@admin_bp.get("/api/admin/videos")
|
||||
@admin_required
|
||||
def list_videos():
|
||||
resp = _api("GET", "/admin/videos", token=session["access_token"])
|
||||
return jsonify(resp.json()), resp.status_code
|
||||
|
||||
|
||||
@admin_bp.post("/api/admin/videos/<job_id>/retry")
|
||||
@admin_required
|
||||
def retry_video(job_id: str):
|
||||
resp = _api(
|
||||
"POST", f"/admin/videos/{job_id}/retry", token=session["access_token"])
|
||||
return jsonify(resp.json()), resp.status_code
|
||||
|
||||
|
||||
@admin_bp.post("/api/admin/videos/<job_id>/cancel")
|
||||
@admin_required
|
||||
def cancel_video(job_id: str):
|
||||
resp = _api(
|
||||
"POST", f"/admin/videos/{job_id}/cancel", token=session["access_token"])
|
||||
return jsonify(resp.json()), resp.status_code
|
||||
|
||||
|
||||
@admin_bp.delete("/api/admin/videos/<job_id>")
|
||||
@admin_required
|
||||
def delete_video(job_id: str):
|
||||
resp = _api(
|
||||
"DELETE", f"/admin/videos/{job_id}", token=session["access_token"])
|
||||
return jsonify(resp.json()), resp.status_code
|
||||
+57
-57
@@ -1,58 +1,58 @@
|
||||
"""Auth blueprint — login, register, logout, index."""
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||
|
||||
from ..helpers import _api
|
||||
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
@auth_bp.get("/")
|
||||
def index():
|
||||
if "access_token" in session:
|
||||
return redirect(url_for("dashboard.index"))
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if request.method == "POST":
|
||||
email = request.form["email"]
|
||||
password = request.form["password"]
|
||||
resp = _api("POST", "/auth/login",
|
||||
json={"email": email, "password": password})
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
session["access_token"] = data["access_token"]
|
||||
session["refresh_token"] = data["refresh_token"]
|
||||
me = _api("GET", "/users/me", token=data["access_token"])
|
||||
if me.status_code == 200:
|
||||
u = me.json()
|
||||
session["user_email"] = u.get("email", "")
|
||||
session["user_role"] = u.get("role", "user")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
flash("Invalid email or password.", "error")
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
@auth_bp.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if request.method == "POST":
|
||||
email = request.form["email"]
|
||||
password = request.form["password"]
|
||||
resp = _api("POST", "/auth/register",
|
||||
json={"email": email, "password": password})
|
||||
if resp.status_code == 201:
|
||||
flash("Account created. Please log in.", "success")
|
||||
return redirect(url_for("auth.login"))
|
||||
detail = resp.json().get("detail", "Registration failed.")
|
||||
flash(detail, "error")
|
||||
return render_template("register.html")
|
||||
|
||||
|
||||
@auth_bp.get("/logout")
|
||||
def logout():
|
||||
refresh_token = session.get("refresh_token")
|
||||
if refresh_token:
|
||||
_api("POST", "/auth/logout", json={"refresh_token": refresh_token})
|
||||
session.clear()
|
||||
"""Auth blueprint — login, register, logout, index."""
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||
|
||||
from ..helpers import _api
|
||||
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
@auth_bp.get("/")
|
||||
def index():
|
||||
if "access_token" in session:
|
||||
return redirect(url_for("dashboard.index"))
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if request.method == "POST":
|
||||
email = request.form["email"]
|
||||
password = request.form["password"]
|
||||
resp = _api("POST", "/auth/login",
|
||||
json={"email": email, "password": password})
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
session["access_token"] = data["access_token"]
|
||||
session["refresh_token"] = data["refresh_token"]
|
||||
me = _api("GET", "/users/me", token=data["access_token"])
|
||||
if me.status_code == 200:
|
||||
u = me.json()
|
||||
session["user_email"] = u.get("email", "")
|
||||
session["user_role"] = u.get("role", "user")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
flash("Invalid email or password.", "error")
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
@auth_bp.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if request.method == "POST":
|
||||
email = request.form["email"]
|
||||
password = request.form["password"]
|
||||
resp = _api("POST", "/auth/register",
|
||||
json={"email": email, "password": password})
|
||||
if resp.status_code == 201:
|
||||
flash("Account created. Please log in.", "success")
|
||||
return redirect(url_for("auth.login"))
|
||||
detail = resp.json().get("detail", "Registration failed.")
|
||||
flash(detail, "error")
|
||||
return render_template("register.html")
|
||||
|
||||
|
||||
@auth_bp.get("/logout")
|
||||
def logout():
|
||||
refresh_token = session.get("refresh_token")
|
||||
if refresh_token:
|
||||
_api("POST", "/auth/logout", json={"refresh_token": refresh_token})
|
||||
session.clear()
|
||||
return redirect(url_for("auth.login"))
|
||||
@@ -1,29 +1,29 @@
|
||||
"""Dashboard blueprint."""
|
||||
from flask import Blueprint, render_template, session
|
||||
|
||||
from ..helpers import _api, login_required
|
||||
|
||||
dashboard_bp = Blueprint("dashboard", __name__)
|
||||
|
||||
|
||||
@dashboard_bp.get("/dashboard")
|
||||
@login_required
|
||||
def index():
|
||||
token = session["access_token"]
|
||||
resp = _api("GET", "/users/me", token=token)
|
||||
user = resp.json() if resp.status_code == 200 else {}
|
||||
img_resp = _api("GET", "/images/", token=token)
|
||||
images = img_resp.json() if img_resp.status_code == 200 else []
|
||||
gen_resp = _api("GET", "/generate/images", token=token)
|
||||
generated_images = gen_resp.json() if gen_resp.status_code == 200 else []
|
||||
|
||||
vid_resp = _api("GET", "/generate/videos", token=token)
|
||||
videos = vid_resp.json() if vid_resp.status_code == 200 else []
|
||||
pending_videos = [v for v in videos if v.get(
|
||||
"status") not in ("completed", "failed")]
|
||||
completed_videos = [v for v in videos if v.get("status") == "completed"]
|
||||
|
||||
return render_template("dashboard.html", user=user, images=images,
|
||||
generated_images=generated_images,
|
||||
pending_videos=pending_videos,
|
||||
"""Dashboard blueprint."""
|
||||
from flask import Blueprint, render_template, session
|
||||
|
||||
from ..helpers import _api, login_required
|
||||
|
||||
dashboard_bp = Blueprint("dashboard", __name__)
|
||||
|
||||
|
||||
@dashboard_bp.get("/dashboard")
|
||||
@login_required
|
||||
def index():
|
||||
token = session["access_token"]
|
||||
resp = _api("GET", "/users/me", token=token)
|
||||
user = resp.json() if resp.status_code == 200 else {}
|
||||
img_resp = _api("GET", "/images/", token=token)
|
||||
images = img_resp.json() if img_resp.status_code == 200 else []
|
||||
gen_resp = _api("GET", "/generate/images", token=token)
|
||||
generated_images = gen_resp.json() if gen_resp.status_code == 200 else []
|
||||
|
||||
vid_resp = _api("GET", "/generate/videos", token=token)
|
||||
videos = vid_resp.json() if vid_resp.status_code == 200 else []
|
||||
pending_videos = [v for v in videos if v.get(
|
||||
"status") not in ("completed", "failed")]
|
||||
completed_videos = [v for v in videos if v.get("status") == "completed"]
|
||||
|
||||
return render_template("dashboard.html", user=user, images=images,
|
||||
generated_images=generated_images,
|
||||
pending_videos=pending_videos,
|
||||
completed_videos=completed_videos)
|
||||
@@ -1,75 +1,75 @@
|
||||
"""Gallery blueprint."""
|
||||
from flask import Blueprint, Response, render_template, session
|
||||
|
||||
from ..helpers import _api, login_required
|
||||
|
||||
gallery_bp = Blueprint("gallery", __name__)
|
||||
|
||||
|
||||
@gallery_bp.get("/gallery")
|
||||
@login_required
|
||||
def index():
|
||||
token = session["access_token"]
|
||||
|
||||
uploads_resp = _api("GET", "/images/", token=token)
|
||||
uploads = uploads_resp.json() if uploads_resp.status_code == 200 else []
|
||||
|
||||
gen_images_resp = _api("GET", "/generate/images", token=token)
|
||||
generated_images = gen_images_resp.json(
|
||||
) if gen_images_resp.status_code == 200 else []
|
||||
|
||||
videos_resp = _api("GET", "/generate/videos", token=token)
|
||||
videos = videos_resp.json() if videos_resp.status_code == 200 else []
|
||||
|
||||
pending_videos = [v for v in videos if v.get(
|
||||
"status") not in ("completed", "failed")]
|
||||
completed_videos = [v for v in videos if v.get("status") == "completed"]
|
||||
|
||||
return render_template(
|
||||
"gallery.html",
|
||||
uploads=uploads,
|
||||
generated_images=generated_images,
|
||||
pending_videos=pending_videos,
|
||||
completed_videos=completed_videos,
|
||||
)
|
||||
|
||||
|
||||
@gallery_bp.get("/gallery/image/<image_id>")
|
||||
@login_required
|
||||
def image_detail(image_id: str):
|
||||
token = session["access_token"]
|
||||
resp = _api("GET", f"/generate/images/{image_id}", token=token)
|
||||
image = resp.json() if resp.status_code == 200 else None
|
||||
return render_template("image_detail.html", image=image)
|
||||
|
||||
|
||||
@gallery_bp.get("/gallery/video/<video_id>")
|
||||
@login_required
|
||||
def video_detail(video_id: str):
|
||||
token = session["access_token"]
|
||||
resp = _api("GET", f"/generate/videos/{video_id}", token=token)
|
||||
video = resp.json() if resp.status_code == 200 else None
|
||||
return render_template("video_detail.html", video=video)
|
||||
|
||||
|
||||
@gallery_bp.get("/gallery/upload/<image_id>")
|
||||
@login_required
|
||||
def upload_detail(image_id: str):
|
||||
token = session["access_token"]
|
||||
resp = _api("GET", f"/images/{image_id}", token=token)
|
||||
image = resp.json() if resp.status_code == 200 else None
|
||||
return render_template("upload_detail.html", image=image)
|
||||
|
||||
|
||||
@gallery_bp.get("/images/<image_id>/file")
|
||||
@login_required
|
||||
def serve_uploaded_image(image_id: str):
|
||||
resp = _api("GET", f"/images/{image_id}/file",
|
||||
token=session["access_token"])
|
||||
if resp.status_code != 200:
|
||||
return Response("Not found", status=404)
|
||||
return Response(
|
||||
resp.content,
|
||||
status=200,
|
||||
content_type=resp.headers.get("content-type", "image/jpeg"),
|
||||
"""Gallery blueprint."""
|
||||
from flask import Blueprint, Response, render_template, session
|
||||
|
||||
from ..helpers import _api, login_required
|
||||
|
||||
gallery_bp = Blueprint("gallery", __name__)
|
||||
|
||||
|
||||
@gallery_bp.get("/gallery")
|
||||
@login_required
|
||||
def index():
|
||||
token = session["access_token"]
|
||||
|
||||
uploads_resp = _api("GET", "/images/", token=token)
|
||||
uploads = uploads_resp.json() if uploads_resp.status_code == 200 else []
|
||||
|
||||
gen_images_resp = _api("GET", "/generate/images", token=token)
|
||||
generated_images = gen_images_resp.json(
|
||||
) if gen_images_resp.status_code == 200 else []
|
||||
|
||||
videos_resp = _api("GET", "/generate/videos", token=token)
|
||||
videos = videos_resp.json() if videos_resp.status_code == 200 else []
|
||||
|
||||
pending_videos = [v for v in videos if v.get(
|
||||
"status") not in ("completed", "failed")]
|
||||
completed_videos = [v for v in videos if v.get("status") == "completed"]
|
||||
|
||||
return render_template(
|
||||
"gallery.html",
|
||||
uploads=uploads,
|
||||
generated_images=generated_images,
|
||||
pending_videos=pending_videos,
|
||||
completed_videos=completed_videos,
|
||||
)
|
||||
|
||||
|
||||
@gallery_bp.get("/gallery/image/<image_id>")
|
||||
@login_required
|
||||
def image_detail(image_id: str):
|
||||
token = session["access_token"]
|
||||
resp = _api("GET", f"/generate/images/{image_id}", token=token)
|
||||
image = resp.json() if resp.status_code == 200 else None
|
||||
return render_template("image_detail.html", image=image)
|
||||
|
||||
|
||||
@gallery_bp.get("/gallery/video/<video_id>")
|
||||
@login_required
|
||||
def video_detail(video_id: str):
|
||||
token = session["access_token"]
|
||||
resp = _api("GET", f"/generate/videos/{video_id}", token=token)
|
||||
video = resp.json() if resp.status_code == 200 else None
|
||||
return render_template("video_detail.html", video=video)
|
||||
|
||||
|
||||
@gallery_bp.get("/gallery/upload/<image_id>")
|
||||
@login_required
|
||||
def upload_detail(image_id: str):
|
||||
token = session["access_token"]
|
||||
resp = _api("GET", f"/images/{image_id}", token=token)
|
||||
image = resp.json() if resp.status_code == 200 else None
|
||||
return render_template("upload_detail.html", image=image)
|
||||
|
||||
|
||||
@gallery_bp.get("/images/<image_id>/file")
|
||||
@login_required
|
||||
def serve_uploaded_image(image_id: str):
|
||||
resp = _api("GET", f"/images/{image_id}/file",
|
||||
token=session["access_token"])
|
||||
if resp.status_code != 200:
|
||||
return Response("Not found", status=404)
|
||||
return Response(
|
||||
resp.content,
|
||||
status=200,
|
||||
content_type=resp.headers.get("content-type", "image/jpeg"),
|
||||
)
|
||||
+206
-206
@@ -1,206 +1,206 @@
|
||||
"""Generate blueprint — text, image, video generation."""
|
||||
import httpx
|
||||
|
||||
from flask import (
|
||||
Blueprint, flash, jsonify, redirect, render_template, request, session, url_for,
|
||||
)
|
||||
|
||||
from ..helpers import _api, _load_models, login_required
|
||||
|
||||
generate_bp = Blueprint("generate", __name__)
|
||||
|
||||
|
||||
def _detail_or_default(resp, default: str) -> str:
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError:
|
||||
return default
|
||||
if isinstance(payload, dict):
|
||||
detail = payload.get("detail")
|
||||
if detail:
|
||||
return str(detail)
|
||||
return default
|
||||
|
||||
|
||||
@generate_bp.get("/generate")
|
||||
@login_required
|
||||
def index():
|
||||
return redirect(url_for("generate.text"))
|
||||
|
||||
|
||||
@generate_bp.route("/generate/text", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def text():
|
||||
error = None
|
||||
token = session["access_token"]
|
||||
chat_history: list[dict] = session.get("chat_history", [])
|
||||
system_prompt: str = session.get("chat_system_prompt", "")
|
||||
model: str = session.get("chat_model", "")
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action", "send")
|
||||
|
||||
if action == "clear":
|
||||
session.pop("chat_history", None)
|
||||
session.pop("chat_system_prompt", None)
|
||||
session.pop("chat_model", None)
|
||||
return redirect(url_for("generate.text"))
|
||||
|
||||
prompt = request.form.get("prompt", "").strip()
|
||||
model = request.form.get("model", "").strip()
|
||||
system_prompt = request.form.get("system_prompt", "").strip()
|
||||
|
||||
session["chat_model"] = model
|
||||
session["chat_system_prompt"] = system_prompt
|
||||
|
||||
if prompt:
|
||||
messages = [m for m in chat_history if m["role"]
|
||||
in ("user", "assistant")]
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
payload: dict = {
|
||||
"model": model,
|
||||
"messages": [{"role": m["role"], "content": m["content"]} for m in messages],
|
||||
}
|
||||
if system_prompt:
|
||||
payload["system_prompt"] = system_prompt
|
||||
|
||||
resp = _api("POST", "/generate/text", token=token, json=payload)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
chat_history = list(messages)
|
||||
chat_history.append({"role": "assistant", "content": data["content"],
|
||||
"usage": data.get("usage")})
|
||||
session["chat_history"] = chat_history
|
||||
else:
|
||||
try:
|
||||
error = resp.json().get("detail", "Generation failed.")
|
||||
except Exception:
|
||||
error = "Generation failed."
|
||||
|
||||
models = _load_models(token, "text")
|
||||
return render_template(
|
||||
"generate_text.html",
|
||||
chat_history=session.get("chat_history", []),
|
||||
error=error,
|
||||
models=models,
|
||||
system_prompt=system_prompt,
|
||||
current_model=model,
|
||||
)
|
||||
|
||||
|
||||
@generate_bp.route("/generate/image", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def image():
|
||||
result = error = None
|
||||
token = session["access_token"]
|
||||
if request.method == "POST":
|
||||
try:
|
||||
ref_file = request.files.get("reference_image")
|
||||
if ref_file and ref_file.filename:
|
||||
up_resp = _api(
|
||||
"POST", "/images/upload",
|
||||
token=token,
|
||||
files={"file": (ref_file.filename,
|
||||
ref_file.stream, ref_file.content_type)},
|
||||
)
|
||||
if up_resp.status_code not in (200, 201):
|
||||
error = _detail_or_default(up_resp, "Image upload failed.")
|
||||
models = _load_models(token, "image")
|
||||
return render_template("generate_image.html", result=result, error=error, models=models)
|
||||
|
||||
resp = _api("POST", "/generate/image", token=token, timeout=120, json={
|
||||
"model": request.form.get("model", "").strip(),
|
||||
"prompt": request.form.get("prompt", "").strip(),
|
||||
"n": int(request.form.get("n", 1)),
|
||||
"size": request.form.get("size", "1024x1024"),
|
||||
"aspect_ratio": request.form.get("aspect_ratio", "").strip() or None,
|
||||
"image_size": request.form.get("image_size", "").strip() or None,
|
||||
})
|
||||
if resp.status_code == 200:
|
||||
result = resp.json()
|
||||
else:
|
||||
error = _detail_or_default(resp, "Generation failed.")
|
||||
except httpx.TimeoutException:
|
||||
error = "Image generation timed out. Please try again."
|
||||
except httpx.RequestError:
|
||||
error = "Cannot reach generation service. Please try again."
|
||||
models = _load_models(token, "image")
|
||||
return render_template("generate_image.html", result=result, error=error, models=models)
|
||||
|
||||
|
||||
@generate_bp.route("/generate/video", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def video():
|
||||
error = None
|
||||
token = session["access_token"]
|
||||
if request.method == "POST":
|
||||
mode = request.form.get("mode", "text")
|
||||
duration_raw = request.form.get("duration_seconds", "")
|
||||
duration = int(
|
||||
duration_raw) if duration_raw.strip().isdigit() else None
|
||||
resolution = request.form.get("resolution", "").strip() or None
|
||||
|
||||
if mode == "image":
|
||||
resp = _api("POST", "/generate/video/from-image", token=token, json={
|
||||
"model": request.form.get("model", "").strip(),
|
||||
"image_url": request.form.get("image_url", "").strip(),
|
||||
"prompt": request.form.get("prompt", "").strip(),
|
||||
"aspect_ratio": request.form.get("aspect_ratio", "16:9"),
|
||||
"duration_seconds": duration,
|
||||
"resolution": resolution,
|
||||
})
|
||||
else:
|
||||
resp = _api("POST", "/generate/video", token=token, json={
|
||||
"model": request.form.get("model", "").strip(),
|
||||
"prompt": request.form.get("prompt", "").strip(),
|
||||
"aspect_ratio": request.form.get("aspect_ratio", "16:9"),
|
||||
"duration_seconds": duration,
|
||||
"resolution": resolution,
|
||||
})
|
||||
|
||||
if resp.status_code == 200:
|
||||
result = resp.json()
|
||||
db_id = result.get("db_id")
|
||||
if db_id:
|
||||
return redirect(url_for("gallery.video_detail", video_id=db_id))
|
||||
flash("Video job started.", "success")
|
||||
return redirect(url_for("gallery.index"))
|
||||
else:
|
||||
error = resp.json().get("detail", "Generation failed.")
|
||||
|
||||
models = _load_models(token, "video")
|
||||
return render_template("generate_video.html", error=error, models=models)
|
||||
|
||||
|
||||
@generate_bp.get("/generate/video/status")
|
||||
@login_required
|
||||
def video_status():
|
||||
"""Proxy video status polling to the backend."""
|
||||
polling_url = request.args.get("polling_url", "")
|
||||
if not polling_url:
|
||||
return jsonify({"error": "polling_url required"}), 400
|
||||
resp = _api(
|
||||
"GET", "/generate/video/status",
|
||||
token=session["access_token"],
|
||||
params={"polling_url": polling_url},
|
||||
)
|
||||
return jsonify(resp.json()), resp.status_code
|
||||
|
||||
|
||||
@generate_bp.get("/generate/video/<video_id>/status")
|
||||
@login_required
|
||||
def video_db_status(video_id: str):
|
||||
"""Return current DB status for a video job (polled by frontend JS)."""
|
||||
resp = _api(
|
||||
"GET", f"/generate/videos/{video_id}", token=session["access_token"])
|
||||
return jsonify(resp.json()), resp.status_code
|
||||
|
||||
|
||||
@generate_bp.post("/generate/video/<video_id>/cancel")
|
||||
@login_required
|
||||
def cancel_video_job(video_id: str):
|
||||
"""Proxy cancel request to backend."""
|
||||
resp = _api(
|
||||
"POST", f"/generate/videos/{video_id}/cancel", token=session["access_token"])
|
||||
return jsonify(resp.json()), resp.status_code
|
||||
"""Generate blueprint — text, image, video generation."""
|
||||
import httpx
|
||||
|
||||
from flask import (
|
||||
Blueprint, flash, jsonify, redirect, render_template, request, session, url_for,
|
||||
)
|
||||
|
||||
from ..helpers import _api, _load_models, login_required
|
||||
|
||||
generate_bp = Blueprint("generate", __name__)
|
||||
|
||||
|
||||
def _detail_or_default(resp, default: str) -> str:
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError:
|
||||
return default
|
||||
if isinstance(payload, dict):
|
||||
detail = payload.get("detail")
|
||||
if detail:
|
||||
return str(detail)
|
||||
return default
|
||||
|
||||
|
||||
@generate_bp.get("/generate")
|
||||
@login_required
|
||||
def index():
|
||||
return redirect(url_for("generate.text"))
|
||||
|
||||
|
||||
@generate_bp.route("/generate/text", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def text():
|
||||
error = None
|
||||
token = session["access_token"]
|
||||
chat_history: list[dict] = session.get("chat_history", [])
|
||||
system_prompt: str = session.get("chat_system_prompt", "")
|
||||
model: str = session.get("chat_model", "")
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action", "send")
|
||||
|
||||
if action == "clear":
|
||||
session.pop("chat_history", None)
|
||||
session.pop("chat_system_prompt", None)
|
||||
session.pop("chat_model", None)
|
||||
return redirect(url_for("generate.text"))
|
||||
|
||||
prompt = request.form.get("prompt", "").strip()
|
||||
model = request.form.get("model", "").strip()
|
||||
system_prompt = request.form.get("system_prompt", "").strip()
|
||||
|
||||
session["chat_model"] = model
|
||||
session["chat_system_prompt"] = system_prompt
|
||||
|
||||
if prompt:
|
||||
messages = [m for m in chat_history if m["role"]
|
||||
in ("user", "assistant")]
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
payload: dict = {
|
||||
"model": model,
|
||||
"messages": [{"role": m["role"], "content": m["content"]} for m in messages],
|
||||
}
|
||||
if system_prompt:
|
||||
payload["system_prompt"] = system_prompt
|
||||
|
||||
resp = _api("POST", "/generate/text", token=token, json=payload)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
chat_history = list(messages)
|
||||
chat_history.append({"role": "assistant", "content": data["content"],
|
||||
"usage": data.get("usage")})
|
||||
session["chat_history"] = chat_history
|
||||
else:
|
||||
try:
|
||||
error = resp.json().get("detail", "Generation failed.")
|
||||
except Exception:
|
||||
error = "Generation failed."
|
||||
|
||||
models = _load_models(token, "text")
|
||||
return render_template(
|
||||
"generate_text.html",
|
||||
chat_history=session.get("chat_history", []),
|
||||
error=error,
|
||||
models=models,
|
||||
system_prompt=system_prompt,
|
||||
current_model=model,
|
||||
)
|
||||
|
||||
|
||||
@generate_bp.route("/generate/image", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def image():
|
||||
result = error = None
|
||||
token = session["access_token"]
|
||||
if request.method == "POST":
|
||||
try:
|
||||
ref_file = request.files.get("reference_image")
|
||||
if ref_file and ref_file.filename:
|
||||
up_resp = _api(
|
||||
"POST", "/images/upload",
|
||||
token=token,
|
||||
files={"file": (ref_file.filename,
|
||||
ref_file.stream, ref_file.content_type)},
|
||||
)
|
||||
if up_resp.status_code not in (200, 201):
|
||||
error = _detail_or_default(up_resp, "Image upload failed.")
|
||||
models = _load_models(token, "image")
|
||||
return render_template("generate_image.html", result=result, error=error, models=models)
|
||||
|
||||
resp = _api("POST", "/generate/image", token=token, timeout=120, json={
|
||||
"model": request.form.get("model", "").strip(),
|
||||
"prompt": request.form.get("prompt", "").strip(),
|
||||
"n": int(request.form.get("n", 1)),
|
||||
"size": request.form.get("size", "1024x1024"),
|
||||
"aspect_ratio": request.form.get("aspect_ratio", "").strip() or None,
|
||||
"image_size": request.form.get("image_size", "").strip() or None,
|
||||
})
|
||||
if resp.status_code == 200:
|
||||
result = resp.json()
|
||||
else:
|
||||
error = _detail_or_default(resp, "Generation failed.")
|
||||
except httpx.TimeoutException:
|
||||
error = "Image generation timed out. Please try again."
|
||||
except httpx.RequestError:
|
||||
error = "Cannot reach generation service. Please try again."
|
||||
models = _load_models(token, "image")
|
||||
return render_template("generate_image.html", result=result, error=error, models=models)
|
||||
|
||||
|
||||
@generate_bp.route("/generate/video", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def video():
|
||||
error = None
|
||||
token = session["access_token"]
|
||||
if request.method == "POST":
|
||||
mode = request.form.get("mode", "text")
|
||||
duration_raw = request.form.get("duration_seconds", "")
|
||||
duration = int(
|
||||
duration_raw) if duration_raw.strip().isdigit() else None
|
||||
resolution = request.form.get("resolution", "").strip() or None
|
||||
|
||||
if mode == "image":
|
||||
resp = _api("POST", "/generate/video/from-image", token=token, json={
|
||||
"model": request.form.get("model", "").strip(),
|
||||
"image_url": request.form.get("image_url", "").strip(),
|
||||
"prompt": request.form.get("prompt", "").strip(),
|
||||
"aspect_ratio": request.form.get("aspect_ratio", "16:9"),
|
||||
"duration_seconds": duration,
|
||||
"resolution": resolution,
|
||||
})
|
||||
else:
|
||||
resp = _api("POST", "/generate/video", token=token, json={
|
||||
"model": request.form.get("model", "").strip(),
|
||||
"prompt": request.form.get("prompt", "").strip(),
|
||||
"aspect_ratio": request.form.get("aspect_ratio", "16:9"),
|
||||
"duration_seconds": duration,
|
||||
"resolution": resolution,
|
||||
})
|
||||
|
||||
if resp.status_code == 200:
|
||||
result = resp.json()
|
||||
db_id = result.get("db_id")
|
||||
if db_id:
|
||||
return redirect(url_for("gallery.video_detail", video_id=db_id))
|
||||
flash("Video job started.", "success")
|
||||
return redirect(url_for("gallery.index"))
|
||||
else:
|
||||
error = resp.json().get("detail", "Generation failed.")
|
||||
|
||||
models = _load_models(token, "video")
|
||||
return render_template("generate_video.html", error=error, models=models)
|
||||
|
||||
|
||||
@generate_bp.get("/generate/video/status")
|
||||
@login_required
|
||||
def video_status():
|
||||
"""Proxy video status polling to the backend."""
|
||||
polling_url = request.args.get("polling_url", "")
|
||||
if not polling_url:
|
||||
return jsonify({"error": "polling_url required"}), 400
|
||||
resp = _api(
|
||||
"GET", "/generate/video/status",
|
||||
token=session["access_token"],
|
||||
params={"polling_url": polling_url},
|
||||
)
|
||||
return jsonify(resp.json()), resp.status_code
|
||||
|
||||
|
||||
@generate_bp.get("/generate/video/<video_id>/status")
|
||||
@login_required
|
||||
def video_db_status(video_id: str):
|
||||
"""Return current DB status for a video job (polled by frontend JS)."""
|
||||
resp = _api(
|
||||
"GET", f"/generate/videos/{video_id}", token=session["access_token"])
|
||||
return jsonify(resp.json()), resp.status_code
|
||||
|
||||
|
||||
@generate_bp.post("/generate/video/<video_id>/cancel")
|
||||
@login_required
|
||||
def cancel_video_job(video_id: str):
|
||||
"""Proxy cancel request to backend."""
|
||||
resp = _api(
|
||||
"POST", f"/generate/videos/{video_id}/cancel", token=session["access_token"])
|
||||
return jsonify(resp.json()), resp.status_code
|
||||
@@ -1,33 +1,33 @@
|
||||
"""Profile blueprint."""
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||
|
||||
from ..helpers import _api, login_required
|
||||
|
||||
profile_bp = Blueprint("profile", __name__)
|
||||
|
||||
|
||||
@profile_bp.route("/users/profile", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def index():
|
||||
token = session["access_token"]
|
||||
if request.method == "POST":
|
||||
payload: dict = {}
|
||||
new_email = request.form.get("email", "").strip()
|
||||
new_password = request.form.get("password", "").strip()
|
||||
if new_email:
|
||||
payload["email"] = new_email
|
||||
if new_password:
|
||||
payload["password"] = new_password
|
||||
if payload:
|
||||
resp = _api("PUT", "/users/me", token=token, json=payload)
|
||||
if resp.status_code == 200:
|
||||
updated = resp.json()
|
||||
session["user_email"] = updated.get(
|
||||
"email", session.get("user_email", ""))
|
||||
flash("Profile updated.", "success")
|
||||
else:
|
||||
flash(resp.json().get("detail", "Update failed."), "error")
|
||||
return redirect(url_for("profile.index"))
|
||||
resp = _api("GET", "/users/me", token=token)
|
||||
user = resp.json() if resp.status_code == 200 else {}
|
||||
"""Profile blueprint."""
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||
|
||||
from ..helpers import _api, login_required
|
||||
|
||||
profile_bp = Blueprint("profile", __name__)
|
||||
|
||||
|
||||
@profile_bp.route("/users/profile", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def index():
|
||||
token = session["access_token"]
|
||||
if request.method == "POST":
|
||||
payload: dict = {}
|
||||
new_email = request.form.get("email", "").strip()
|
||||
new_password = request.form.get("password", "").strip()
|
||||
if new_email:
|
||||
payload["email"] = new_email
|
||||
if new_password:
|
||||
payload["password"] = new_password
|
||||
if payload:
|
||||
resp = _api("PUT", "/users/me", token=token, json=payload)
|
||||
if resp.status_code == 200:
|
||||
updated = resp.json()
|
||||
session["user_email"] = updated.get(
|
||||
"email", session.get("user_email", ""))
|
||||
flash("Profile updated.", "success")
|
||||
else:
|
||||
flash(resp.json().get("detail", "Update failed."), "error")
|
||||
return redirect(url_for("profile.index"))
|
||||
resp = _api("GET", "/users/me", token=token)
|
||||
user = resp.json() if resp.status_code == 200 else {}
|
||||
return render_template("profile.html", user=user)
|
||||
+174
-174
@@ -1,174 +1,174 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// ── Loading overlay ────────────────────────────────────
|
||||
const overlay = document.getElementById("loading-overlay");
|
||||
|
||||
document.querySelectorAll("form").forEach((form) => {
|
||||
form.addEventListener("submit", () => {
|
||||
if (overlay) overlay.classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Hamburger menu ─────────────────────────────────────
|
||||
const hamburger = document.querySelector(".hamburger");
|
||||
const navLinks = document.querySelector(".nav-links");
|
||||
|
||||
if (hamburger && navLinks) {
|
||||
hamburger.addEventListener("click", () => {
|
||||
navLinks.classList.toggle("open");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Image upload preview ───────────────────────────────
|
||||
const imageInput = document.getElementById("reference_image");
|
||||
const imagePreviewWrap = document.getElementById("image-upload-preview");
|
||||
const imagePreview = document.getElementById("image-upload-preview-img");
|
||||
const imageFilename = document.getElementById("image-upload-filename");
|
||||
|
||||
if (imageInput && imagePreviewWrap && imagePreview && imageFilename) {
|
||||
imageInput.addEventListener("change", () => {
|
||||
const file = imageInput.files && imageInput.files[0];
|
||||
if (!file) {
|
||||
imagePreviewWrap.hidden = true;
|
||||
imagePreview.removeAttribute("src");
|
||||
imageFilename.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
imagePreview.src = URL.createObjectURL(file);
|
||||
imageFilename.textContent = file.name;
|
||||
imagePreviewWrap.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Generate dropdown tabs ─────────────────────────────
|
||||
document.querySelectorAll(".tab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = btn.dataset.tab;
|
||||
const container = btn.closest(".tabs-container");
|
||||
if (!container) return;
|
||||
|
||||
container
|
||||
.querySelectorAll(".tab-btn")
|
||||
.forEach((b) => b.classList.remove("active"));
|
||||
container
|
||||
.querySelectorAll(".tab-panel")
|
||||
.forEach((p) => p.classList.remove("active"));
|
||||
|
||||
btn.classList.add("active");
|
||||
const panel = container.querySelector(`#tab-${target}`);
|
||||
if (panel) panel.classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Video status polling ───────────────────────────────
|
||||
const pollDiv = document.getElementById("video-poll-status");
|
||||
if (pollDiv) {
|
||||
const videoId = pollDiv.dataset.videoId;
|
||||
const statusText = document.getElementById("poll-status-text");
|
||||
const videoContainer = document.getElementById("poll-video-container");
|
||||
const cancelBtn = document.getElementById("cancel-video-btn");
|
||||
const cancelMsg = document.getElementById("cancel-msg");
|
||||
const MAX_POLLS = 120; // ~10 minutes at 5s interval
|
||||
let pollCount = 0;
|
||||
let interval = null;
|
||||
|
||||
const stopPolling = () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener("click", async () => {
|
||||
cancelBtn.disabled = true;
|
||||
cancelBtn.textContent = "Cancelling…";
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/generate/video/" + encodeURIComponent(videoId) + "/cancel",
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (resp.ok) {
|
||||
stopPolling();
|
||||
cancelBtn.classList.add("hidden");
|
||||
if (cancelMsg) {
|
||||
cancelMsg.textContent = "Job cancelled.";
|
||||
cancelMsg.classList.remove("hidden", "text-red-500");
|
||||
cancelMsg.classList.add("text-gray-300");
|
||||
}
|
||||
if (statusText) {
|
||||
statusText.innerHTML = "Status: <strong>cancelled</strong>";
|
||||
}
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.textContent = "Cancel Job";
|
||||
if (cancelMsg) {
|
||||
cancelMsg.textContent = data.detail || "Cancel failed.";
|
||||
cancelMsg.classList.remove("hidden");
|
||||
cancelMsg.classList.add("text-red-500");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.textContent = "Cancel Job";
|
||||
if (cancelMsg) {
|
||||
cancelMsg.textContent = "Network error.";
|
||||
cancelMsg.classList.remove("hidden");
|
||||
cancelMsg.classList.add("text-red-500");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interval = setInterval(async () => {
|
||||
try {
|
||||
pollCount++;
|
||||
if (pollCount > MAX_POLLS) {
|
||||
stopPolling();
|
||||
pollDiv.innerHTML =
|
||||
'<div class="alert alert-warning">Polling timed out. Please refresh the page to check status.</div>';
|
||||
return;
|
||||
}
|
||||
const resp = await fetch(
|
||||
"/generate/video/" + encodeURIComponent(videoId) + "/status",
|
||||
);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
|
||||
if (statusText) {
|
||||
statusText.innerHTML = "Status: <strong>" + data.status + "</strong>";
|
||||
}
|
||||
|
||||
if (data.status === "completed") {
|
||||
stopPolling();
|
||||
if (data.video_url) {
|
||||
if (videoContainer) {
|
||||
const vid = document.createElement("video");
|
||||
vid.src = data.video_url;
|
||||
vid.controls = true;
|
||||
vid.className = "generated-video";
|
||||
videoContainer.appendChild(vid);
|
||||
const msg = pollDiv.querySelector("p");
|
||||
if (msg) msg.textContent = "Video ready!";
|
||||
} else {
|
||||
// video_detail page: reload to show the video element
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
} else if (data.status === "failed") {
|
||||
stopPolling();
|
||||
pollDiv.innerHTML =
|
||||
'<div class="alert alert-error">Generation failed.</div>';
|
||||
} else if (data.status === "cancelled") {
|
||||
stopPolling();
|
||||
if (cancelBtn) cancelBtn.classList.add("hidden");
|
||||
pollDiv.innerHTML =
|
||||
'<div class="alert alert-info">Job was cancelled.</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Video polling error:", e);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// ── Loading overlay ────────────────────────────────────
|
||||
const overlay = document.getElementById("loading-overlay");
|
||||
|
||||
document.querySelectorAll("form").forEach((form) => {
|
||||
form.addEventListener("submit", () => {
|
||||
if (overlay) overlay.classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Hamburger menu ─────────────────────────────────────
|
||||
const hamburger = document.querySelector(".hamburger");
|
||||
const navLinks = document.querySelector(".nav-links");
|
||||
|
||||
if (hamburger && navLinks) {
|
||||
hamburger.addEventListener("click", () => {
|
||||
navLinks.classList.toggle("open");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Image upload preview ───────────────────────────────
|
||||
const imageInput = document.getElementById("reference_image");
|
||||
const imagePreviewWrap = document.getElementById("image-upload-preview");
|
||||
const imagePreview = document.getElementById("image-upload-preview-img");
|
||||
const imageFilename = document.getElementById("image-upload-filename");
|
||||
|
||||
if (imageInput && imagePreviewWrap && imagePreview && imageFilename) {
|
||||
imageInput.addEventListener("change", () => {
|
||||
const file = imageInput.files && imageInput.files[0];
|
||||
if (!file) {
|
||||
imagePreviewWrap.hidden = true;
|
||||
imagePreview.removeAttribute("src");
|
||||
imageFilename.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
imagePreview.src = URL.createObjectURL(file);
|
||||
imageFilename.textContent = file.name;
|
||||
imagePreviewWrap.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Generate dropdown tabs ─────────────────────────────
|
||||
document.querySelectorAll(".tab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = btn.dataset.tab;
|
||||
const container = btn.closest(".tabs-container");
|
||||
if (!container) return;
|
||||
|
||||
container
|
||||
.querySelectorAll(".tab-btn")
|
||||
.forEach((b) => b.classList.remove("active"));
|
||||
container
|
||||
.querySelectorAll(".tab-panel")
|
||||
.forEach((p) => p.classList.remove("active"));
|
||||
|
||||
btn.classList.add("active");
|
||||
const panel = container.querySelector(`#tab-${target}`);
|
||||
if (panel) panel.classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Video status polling ───────────────────────────────
|
||||
const pollDiv = document.getElementById("video-poll-status");
|
||||
if (pollDiv) {
|
||||
const videoId = pollDiv.dataset.videoId;
|
||||
const statusText = document.getElementById("poll-status-text");
|
||||
const videoContainer = document.getElementById("poll-video-container");
|
||||
const cancelBtn = document.getElementById("cancel-video-btn");
|
||||
const cancelMsg = document.getElementById("cancel-msg");
|
||||
const MAX_POLLS = 120; // ~10 minutes at 5s interval
|
||||
let pollCount = 0;
|
||||
let interval = null;
|
||||
|
||||
const stopPolling = () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener("click", async () => {
|
||||
cancelBtn.disabled = true;
|
||||
cancelBtn.textContent = "Cancelling…";
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/generate/video/" + encodeURIComponent(videoId) + "/cancel",
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (resp.ok) {
|
||||
stopPolling();
|
||||
cancelBtn.classList.add("hidden");
|
||||
if (cancelMsg) {
|
||||
cancelMsg.textContent = "Job cancelled.";
|
||||
cancelMsg.classList.remove("hidden", "text-red-500");
|
||||
cancelMsg.classList.add("text-gray-300");
|
||||
}
|
||||
if (statusText) {
|
||||
statusText.innerHTML = "Status: <strong>cancelled</strong>";
|
||||
}
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.textContent = "Cancel Job";
|
||||
if (cancelMsg) {
|
||||
cancelMsg.textContent = data.detail || "Cancel failed.";
|
||||
cancelMsg.classList.remove("hidden");
|
||||
cancelMsg.classList.add("text-red-500");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.textContent = "Cancel Job";
|
||||
if (cancelMsg) {
|
||||
cancelMsg.textContent = "Network error.";
|
||||
cancelMsg.classList.remove("hidden");
|
||||
cancelMsg.classList.add("text-red-500");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interval = setInterval(async () => {
|
||||
try {
|
||||
pollCount++;
|
||||
if (pollCount > MAX_POLLS) {
|
||||
stopPolling();
|
||||
pollDiv.innerHTML =
|
||||
'<div class="alert alert-warning">Polling timed out. Please refresh the page to check status.</div>';
|
||||
return;
|
||||
}
|
||||
const resp = await fetch(
|
||||
"/generate/video/" + encodeURIComponent(videoId) + "/status",
|
||||
);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
|
||||
if (statusText) {
|
||||
statusText.innerHTML = "Status: <strong>" + data.status + "</strong>";
|
||||
}
|
||||
|
||||
if (data.status === "completed") {
|
||||
stopPolling();
|
||||
if (data.video_url) {
|
||||
if (videoContainer) {
|
||||
const vid = document.createElement("video");
|
||||
vid.src = data.video_url;
|
||||
vid.controls = true;
|
||||
vid.className = "generated-video";
|
||||
videoContainer.appendChild(vid);
|
||||
const msg = pollDiv.querySelector("p");
|
||||
if (msg) msg.textContent = "Video ready!";
|
||||
} else {
|
||||
// video_detail page: reload to show the video element
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
} else if (data.status === "failed") {
|
||||
stopPolling();
|
||||
pollDiv.innerHTML =
|
||||
'<div class="alert alert-error">Generation failed.</div>';
|
||||
} else if (data.status === "cancelled") {
|
||||
stopPolling();
|
||||
if (cancelBtn) cancelBtn.classList.add("hidden");
|
||||
pollDiv.innerHTML =
|
||||
'<div class="alert alert-info">Job was cancelled.</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Video polling error:", e);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
+817
-817
File diff suppressed because it is too large.
Load diff
+279
-279
@@ -1,279 +1,279 @@
|
||||
{% extends "base.html" %} {% block title %}Admin — All You Can GET AI{% endblock
|
||||
%} {% block content %}
|
||||
<div class="card admin-page">
|
||||
<h1>Admin Dashboard</h1>
|
||||
|
||||
{% if stats %}
|
||||
<div class="stats-grid">
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Total users</div>
|
||||
<div class="stat-value">{{ stats.get('total_users', 0) }}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Active tokens</div>
|
||||
<div class="stat-value">{{ stats.get('active_refresh_tokens', 0) }}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Admins</div>
|
||||
<div class="stat-value">{{ stats.get('admin_users', 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2 class="section-title">Users</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.email }}</td>
|
||||
<td>
|
||||
<span class="role-badge role-{{ u.role }}">{{ u.role }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="table-actions">
|
||||
<!-- Role toggle -->
|
||||
<form
|
||||
method="post"
|
||||
action="{{ url_for('admin.set_role', user_id=u.id) }}"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="role"
|
||||
value="{{ 'user' if u.role == 'admin' else 'admin' }}"
|
||||
/>
|
||||
<button type="submit" class="btn btn-sm">
|
||||
Make {{ 'user' if u.role == 'admin' else 'admin' }}
|
||||
</button>
|
||||
</form>
|
||||
<!-- Delete -->
|
||||
{% if u.id != session.get('user_id') %}
|
||||
<form
|
||||
method="post"
|
||||
action="{{ url_for('admin.delete_user', user_id=u.id) }}"
|
||||
onsubmit="return confirm('Delete {{ u.email }}?')"
|
||||
>
|
||||
<button type="submit" class="btn btn-sm btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-muted">No users found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ── Video Jobs ──────────────────────────────────────────────── -->
|
||||
<h2 class="section-title" style="margin-top: 2rem">Video Jobs</h2>
|
||||
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
"
|
||||
>
|
||||
<label for="vj-status-filter" style="font-weight: 600"
|
||||
>Filter by status:</label
|
||||
>
|
||||
<select id="vj-status-filter" class="form-control" style="width: auto">
|
||||
<option value="">All</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<label for="vj-sort" style="font-weight: 600">Sort:</label>
|
||||
<select id="vj-sort" class="form-control" style="width: auto">
|
||||
<option value="created_desc">Created (newest first)</option>
|
||||
<option value="created_asc">Created (oldest first)</option>
|
||||
<option value="updated_desc">Updated (newest first)</option>
|
||||
<option value="status_asc">Status (A–Z)</option>
|
||||
<option value="model_asc">Model (A–Z)</option>
|
||||
</select>
|
||||
<button id="vj-refresh" class="btn btn-sm">Refresh</button>
|
||||
<span
|
||||
id="vj-count"
|
||||
style="color: var(--text-muted, #888); font-size: 0.9em"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table id="vj-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Status</th>
|
||||
<th>Model</th>
|
||||
<th>Prompt</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="vj-tbody">
|
||||
<tr>
|
||||
<td colspan="7" class="text-muted">Loading…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
let allJobs = [];
|
||||
|
||||
async function loadJobs() {
|
||||
document.getElementById("vj-tbody").innerHTML =
|
||||
'<tr><td colspan="7" class="text-muted">Loading…</td></tr>';
|
||||
try {
|
||||
const r = await fetch("/api/admin/videos");
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
allJobs = await r.json();
|
||||
renderJobs();
|
||||
} catch (e) {
|
||||
document.getElementById("vj-tbody").innerHTML =
|
||||
`<tr><td colspan="7" style="color:red;">Error: ${e.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderJobs() {
|
||||
const statusFilter = document.getElementById("vj-status-filter").value;
|
||||
const sort = document.getElementById("vj-sort").value;
|
||||
|
||||
let jobs = statusFilter
|
||||
? allJobs.filter((j) => j.status === statusFilter)
|
||||
: [...allJobs];
|
||||
|
||||
jobs.sort((a, b) => {
|
||||
if (sort === "created_asc")
|
||||
return new Date(a.created_at) - new Date(b.created_at);
|
||||
if (sort === "updated_desc")
|
||||
return new Date(b.updated_at) - new Date(a.updated_at);
|
||||
if (sort === "status_asc") return a.status.localeCompare(b.status);
|
||||
if (sort === "model_asc") return a.model_id.localeCompare(b.model_id);
|
||||
return new Date(b.created_at) - new Date(a.created_at); // created_desc default
|
||||
});
|
||||
|
||||
document.getElementById("vj-count").textContent =
|
||||
`${jobs.length} job${jobs.length !== 1 ? "s" : ""}`;
|
||||
|
||||
const tbody = document.getElementById("vj-tbody");
|
||||
if (jobs.length === 0) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="7" class="text-muted">No jobs found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const statusColor = {
|
||||
completed: "color:var(--success-color,#4caf50)",
|
||||
failed: "color:var(--danger-color,#e53935)",
|
||||
cancelled: "color:var(--danger-color,#e53935)",
|
||||
processing: "color:var(--warning-color,#fb8c00)",
|
||||
queued: "color:var(--warning-color,#fb8c00)",
|
||||
};
|
||||
|
||||
tbody.innerHTML = jobs
|
||||
.map((job) => {
|
||||
const sc = statusColor[job.status] || "";
|
||||
const canRetry =
|
||||
job.status === "failed" || job.status === "cancelled";
|
||||
const canCancel =
|
||||
job.status === "queued" || job.status === "processing";
|
||||
const actions = [
|
||||
canRetry
|
||||
? `<button class="btn btn-sm vj-retry" data-id="${job.id}">Retry</button>`
|
||||
: "",
|
||||
canCancel
|
||||
? `<button class="btn btn-sm vj-cancel" data-id="${job.id}">Cancel</button>`
|
||||
: "",
|
||||
`<button class="btn btn-sm btn-danger vj-delete" data-id="${job.id}">Delete</button>`,
|
||||
].join(" ");
|
||||
const prompt =
|
||||
job.prompt.length > 60 ? job.prompt.slice(0, 57) + "…" : job.prompt;
|
||||
const created = job.created_at
|
||||
? new Date(job.created_at).toLocaleString()
|
||||
: "—";
|
||||
const updated = job.updated_at
|
||||
? new Date(job.updated_at).toLocaleString()
|
||||
: "—";
|
||||
return `<tr>
|
||||
<td>${job.user_email || "—"}</td>
|
||||
<td style="${sc};font-weight:600;">${job.status}</td>
|
||||
<td style="font-size:.85em;">${job.model_id}</td>
|
||||
<td title="${job.prompt.replace(/"/g, """)}">${prompt}</td>
|
||||
<td style="white-space:nowrap;">${created}</td>
|
||||
<td style="white-space:nowrap;">${updated}</td>
|
||||
<td style="white-space:nowrap;">${actions}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function apiPost(path) {
|
||||
const r = await fetch(path, { method: "POST" });
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
throw new Error(d.detail || r.statusText);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function apiDelete(path) {
|
||||
const r = await fetch(path, { method: "DELETE" });
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
throw new Error(d.detail || r.statusText);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
document
|
||||
.getElementById("vj-tbody")
|
||||
.addEventListener("click", async function (e) {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
const id = btn.dataset.id;
|
||||
try {
|
||||
if (btn.classList.contains("vj-retry"))
|
||||
await apiPost(`/api/admin/videos/${id}/retry`);
|
||||
if (btn.classList.contains("vj-cancel"))
|
||||
await apiPost(`/api/admin/videos/${id}/cancel`);
|
||||
if (btn.classList.contains("vj-delete")) {
|
||||
if (!confirm("Permanently delete this video job?")) return;
|
||||
await apiDelete(`/api/admin/videos/${id}`);
|
||||
}
|
||||
await loadJobs();
|
||||
} catch (err) {
|
||||
alert("Error: " + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById("vj-status-filter")
|
||||
.addEventListener("change", renderJobs);
|
||||
document.getElementById("vj-sort").addEventListener("change", renderJobs);
|
||||
document.getElementById("vj-refresh").addEventListener("click", loadJobs);
|
||||
|
||||
loadJobs();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %} {% block title %}Admin — All You Can GET AI{% endblock
|
||||
%} {% block content %}
|
||||
<div class="card admin-page">
|
||||
<h1>Admin Dashboard</h1>
|
||||
|
||||
{% if stats %}
|
||||
<div class="stats-grid">
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Total users</div>
|
||||
<div class="stat-value">{{ stats.get('total_users', 0) }}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Active tokens</div>
|
||||
<div class="stat-value">{{ stats.get('active_refresh_tokens', 0) }}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Admins</div>
|
||||
<div class="stat-value">{{ stats.get('admin_users', 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2 class="section-title">Users</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.email }}</td>
|
||||
<td>
|
||||
<span class="role-badge role-{{ u.role }}">{{ u.role }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="table-actions">
|
||||
<!-- Role toggle -->
|
||||
<form
|
||||
method="post"
|
||||
action="{{ url_for('admin.set_role', user_id=u.id) }}"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="role"
|
||||
value="{{ 'user' if u.role == 'admin' else 'admin' }}"
|
||||
/>
|
||||
<button type="submit" class="btn btn-sm">
|
||||
Make {{ 'user' if u.role == 'admin' else 'admin' }}
|
||||
</button>
|
||||
</form>
|
||||
<!-- Delete -->
|
||||
{% if u.id != session.get('user_id') %}
|
||||
<form
|
||||
method="post"
|
||||
action="{{ url_for('admin.delete_user', user_id=u.id) }}"
|
||||
onsubmit="return confirm('Delete {{ u.email }}?')"
|
||||
>
|
||||
<button type="submit" class="btn btn-sm btn-danger">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-muted">No users found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ── Video Jobs ──────────────────────────────────────────────── -->
|
||||
<h2 class="section-title" style="margin-top: 2rem">Video Jobs</h2>
|
||||
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
"
|
||||
>
|
||||
<label for="vj-status-filter" style="font-weight: 600"
|
||||
>Filter by status:</label
|
||||
>
|
||||
<select id="vj-status-filter" class="form-control" style="width: auto">
|
||||
<option value="">All</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<label for="vj-sort" style="font-weight: 600">Sort:</label>
|
||||
<select id="vj-sort" class="form-control" style="width: auto">
|
||||
<option value="created_desc">Created (newest first)</option>
|
||||
<option value="created_asc">Created (oldest first)</option>
|
||||
<option value="updated_desc">Updated (newest first)</option>
|
||||
<option value="status_asc">Status (A–Z)</option>
|
||||
<option value="model_asc">Model (A–Z)</option>
|
||||
</select>
|
||||
<button id="vj-refresh" class="btn btn-sm">Refresh</button>
|
||||
<span
|
||||
id="vj-count"
|
||||
style="color: var(--text-muted, #888); font-size: 0.9em"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table id="vj-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Status</th>
|
||||
<th>Model</th>
|
||||
<th>Prompt</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="vj-tbody">
|
||||
<tr>
|
||||
<td colspan="7" class="text-muted">Loading…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
let allJobs = [];
|
||||
|
||||
async function loadJobs() {
|
||||
document.getElementById("vj-tbody").innerHTML =
|
||||
'<tr><td colspan="7" class="text-muted">Loading…</td></tr>';
|
||||
try {
|
||||
const r = await fetch("/api/admin/videos");
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
allJobs = await r.json();
|
||||
renderJobs();
|
||||
} catch (e) {
|
||||
document.getElementById("vj-tbody").innerHTML =
|
||||
`<tr><td colspan="7" style="color:red;">Error: ${e.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderJobs() {
|
||||
const statusFilter = document.getElementById("vj-status-filter").value;
|
||||
const sort = document.getElementById("vj-sort").value;
|
||||
|
||||
let jobs = statusFilter
|
||||
? allJobs.filter((j) => j.status === statusFilter)
|
||||
: [...allJobs];
|
||||
|
||||
jobs.sort((a, b) => {
|
||||
if (sort === "created_asc")
|
||||
return new Date(a.created_at) - new Date(b.created_at);
|
||||
if (sort === "updated_desc")
|
||||
return new Date(b.updated_at) - new Date(a.updated_at);
|
||||
if (sort === "status_asc") return a.status.localeCompare(b.status);
|
||||
if (sort === "model_asc") return a.model_id.localeCompare(b.model_id);
|
||||
return new Date(b.created_at) - new Date(a.created_at); // created_desc default
|
||||
});
|
||||
|
||||
document.getElementById("vj-count").textContent =
|
||||
`${jobs.length} job${jobs.length !== 1 ? "s" : ""}`;
|
||||
|
||||
const tbody = document.getElementById("vj-tbody");
|
||||
if (jobs.length === 0) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="7" class="text-muted">No jobs found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const statusColor = {
|
||||
completed: "color:var(--success-color,#4caf50)",
|
||||
failed: "color:var(--danger-color,#e53935)",
|
||||
cancelled: "color:var(--danger-color,#e53935)",
|
||||
processing: "color:var(--warning-color,#fb8c00)",
|
||||
queued: "color:var(--warning-color,#fb8c00)",
|
||||
};
|
||||
|
||||
tbody.innerHTML = jobs
|
||||
.map((job) => {
|
||||
const sc = statusColor[job.status] || "";
|
||||
const canRetry =
|
||||
job.status === "failed" || job.status === "cancelled";
|
||||
const canCancel =
|
||||
job.status === "queued" || job.status === "processing";
|
||||
const actions = [
|
||||
canRetry
|
||||
? `<button class="btn btn-sm vj-retry" data-id="${job.id}">Retry</button>`
|
||||
: "",
|
||||
canCancel
|
||||
? `<button class="btn btn-sm vj-cancel" data-id="${job.id}">Cancel</button>`
|
||||
: "",
|
||||
`<button class="btn btn-sm btn-danger vj-delete" data-id="${job.id}">Delete</button>`,
|
||||
].join(" ");
|
||||
const prompt =
|
||||
job.prompt.length > 60 ? job.prompt.slice(0, 57) + "…" : job.prompt;
|
||||
const created = job.created_at
|
||||
? new Date(job.created_at).toLocaleString()
|
||||
: "—";
|
||||
const updated = job.updated_at
|
||||
? new Date(job.updated_at).toLocaleString()
|
||||
: "—";
|
||||
return `<tr>
|
||||
<td>${job.user_email || "—"}</td>
|
||||
<td style="${sc};font-weight:600;">${job.status}</td>
|
||||
<td style="font-size:.85em;">${job.model_id}</td>
|
||||
<td title="${job.prompt.replace(/"/g, """)}">${prompt}</td>
|
||||
<td style="white-space:nowrap;">${created}</td>
|
||||
<td style="white-space:nowrap;">${updated}</td>
|
||||
<td style="white-space:nowrap;">${actions}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function apiPost(path) {
|
||||
const r = await fetch(path, { method: "POST" });
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
throw new Error(d.detail || r.statusText);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function apiDelete(path) {
|
||||
const r = await fetch(path, { method: "DELETE" });
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
throw new Error(d.detail || r.statusText);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
document
|
||||
.getElementById("vj-tbody")
|
||||
.addEventListener("click", async function (e) {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
const id = btn.dataset.id;
|
||||
try {
|
||||
if (btn.classList.contains("vj-retry"))
|
||||
await apiPost(`/api/admin/videos/${id}/retry`);
|
||||
if (btn.classList.contains("vj-cancel"))
|
||||
await apiPost(`/api/admin/videos/${id}/cancel`);
|
||||
if (btn.classList.contains("vj-delete")) {
|
||||
if (!confirm("Permanently delete this video job?")) return;
|
||||
await apiDelete(`/api/admin/videos/${id}`);
|
||||
}
|
||||
await loadJobs();
|
||||
} catch (err) {
|
||||
alert("Error: " + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById("vj-status-filter")
|
||||
.addEventListener("change", renderJobs);
|
||||
document.getElementById("vj-sort").addEventListener("change", renderJobs);
|
||||
document.getElementById("vj-refresh").addEventListener("click", loadJobs);
|
||||
|
||||
loadJobs();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,154 +1,154 @@
|
||||
{% extends "base.html" %} {% block title %}Admin - Model Management{% endblock
|
||||
%} {% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-6">Admin: Model Management</h1>
|
||||
|
||||
<!-- Cache Status -->
|
||||
<div class="bg-gray-800 p-4 rounded-lg shadow-md mb-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Cache Status</h2>
|
||||
<div id="cache-status" class="grid grid-cols-2 gap-4">
|
||||
<p>
|
||||
<strong>Last Updated:</strong> <span id="last-updated">Loading...</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Model Count:</strong> <span id="model-count">Loading...</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
id="refresh-button"
|
||||
class="mt-4 bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Refresh Cache
|
||||
</button>
|
||||
<p id="refresh-status" class="mt-2 text-sm"></p>
|
||||
</div>
|
||||
|
||||
<!-- Model List -->
|
||||
<div class="bg-gray-800 p-4 rounded-lg shadow-md">
|
||||
<h2 class="text-xl font-semibold mb-2">Available Models</h2>
|
||||
<table id="models-table" class="min-w-full divide-y divide-gray-700">
|
||||
<thead class="bg-gray-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
ID
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
Modality
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
Context Length
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
id="models-table-body"
|
||||
class="bg-gray-800 divide-y divide-gray-700"
|
||||
>
|
||||
<!-- Data will be populated by JavaScript -->
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-4">Loading models...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const lastUpdatedEl = document.getElementById("last-updated");
|
||||
const modelCountEl = document.getElementById("model-count");
|
||||
const modelsTableBody = document.getElementById("models-table-body");
|
||||
const refreshButton = document.getElementById("refresh-button");
|
||||
const refreshStatus = document.getElementById("refresh-status");
|
||||
|
||||
async function fetchCacheStatus() {
|
||||
try {
|
||||
const response = await fetch("/api/v1/admin/models/status");
|
||||
if (!response.ok) throw new Error("Failed to fetch status");
|
||||
const data = await response.json();
|
||||
lastUpdatedEl.textContent = data.last_updated
|
||||
? new Date(data.last_updated).toLocaleString()
|
||||
: "Never";
|
||||
modelCountEl.textContent = data.model_count;
|
||||
} catch (error) {
|
||||
lastUpdatedEl.textContent = "Error";
|
||||
modelCountEl.textContent = "Error";
|
||||
console.error("Error fetching cache status:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchModels() {
|
||||
try {
|
||||
const response = await fetch("/api/v1/admin/models");
|
||||
if (!response.ok) throw new Error("Failed to fetch models");
|
||||
const models = await response.json();
|
||||
modelsTableBody.innerHTML = ""; // Clear loading message
|
||||
if (models.length === 0) {
|
||||
modelsTableBody.innerHTML =
|
||||
'<tr><td colspan="4" class="text-center py-4">No models found in cache.</td></tr>';
|
||||
} else {
|
||||
models.forEach((model) => {
|
||||
const row = `
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">${model.name}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap font-mono text-sm">${model.id}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">${model.modality}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">${model.context_length || "N/A"}</td>
|
||||
</tr>
|
||||
`;
|
||||
modelsTableBody.innerHTML += row;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
modelsTableBody.innerHTML =
|
||||
'<tr><td colspan="4" class="text-center py-4 text-red-500">Error loading models.</td></tr>';
|
||||
console.error("Error fetching models:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCache() {
|
||||
refreshButton.disabled = true;
|
||||
refreshStatus.textContent = "Refreshing...";
|
||||
refreshStatus.classList.remove("text-red-500", "text-green-500");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/v1/admin/models/refresh", {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || "Failed to refresh cache");
|
||||
}
|
||||
refreshStatus.textContent = `Successfully refreshed ${data.refreshed} models. Total: ${data.total_models}.`;
|
||||
refreshStatus.classList.add("text-green-500");
|
||||
fetchCacheStatus();
|
||||
fetchModels();
|
||||
} catch (error) {
|
||||
refreshStatus.textContent = `Error: ${error.message}`;
|
||||
refreshStatus.classList.add("text-red-500");
|
||||
} finally {
|
||||
refreshButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
fetchCacheStatus();
|
||||
fetchModels();
|
||||
refreshButton.addEventListener("click", refreshCache);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %} {% block title %}Admin - Model Management{% endblock
|
||||
%} {% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-6">Admin: Model Management</h1>
|
||||
|
||||
<!-- Cache Status -->
|
||||
<div class="bg-gray-800 p-4 rounded-lg shadow-md mb-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Cache Status</h2>
|
||||
<div id="cache-status" class="grid grid-cols-2 gap-4">
|
||||
<p>
|
||||
<strong>Last Updated:</strong> <span id="last-updated">Loading...</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Model Count:</strong> <span id="model-count">Loading...</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
id="refresh-button"
|
||||
class="mt-4 bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Refresh Cache
|
||||
</button>
|
||||
<p id="refresh-status" class="mt-2 text-sm"></p>
|
||||
</div>
|
||||
|
||||
<!-- Model List -->
|
||||
<div class="bg-gray-800 p-4 rounded-lg shadow-md">
|
||||
<h2 class="text-xl font-semibold mb-2">Available Models</h2>
|
||||
<table id="models-table" class="min-w-full divide-y divide-gray-700">
|
||||
<thead class="bg-gray-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
ID
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
Modality
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
Context Length
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
id="models-table-body"
|
||||
class="bg-gray-800 divide-y divide-gray-700"
|
||||
>
|
||||
<!-- Data will be populated by JavaScript -->
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-4">Loading models...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const lastUpdatedEl = document.getElementById("last-updated");
|
||||
const modelCountEl = document.getElementById("model-count");
|
||||
const modelsTableBody = document.getElementById("models-table-body");
|
||||
const refreshButton = document.getElementById("refresh-button");
|
||||
const refreshStatus = document.getElementById("refresh-status");
|
||||
|
||||
async function fetchCacheStatus() {
|
||||
try {
|
||||
const response = await fetch("/api/v1/admin/models/status");
|
||||
if (!response.ok) throw new Error("Failed to fetch status");
|
||||
const data = await response.json();
|
||||
lastUpdatedEl.textContent = data.last_updated
|
||||
? new Date(data.last_updated).toLocaleString()
|
||||
: "Never";
|
||||
modelCountEl.textContent = data.model_count;
|
||||
} catch (error) {
|
||||
lastUpdatedEl.textContent = "Error";
|
||||
modelCountEl.textContent = "Error";
|
||||
console.error("Error fetching cache status:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchModels() {
|
||||
try {
|
||||
const response = await fetch("/api/v1/admin/models");
|
||||
if (!response.ok) throw new Error("Failed to fetch models");
|
||||
const models = await response.json();
|
||||
modelsTableBody.innerHTML = ""; // Clear loading message
|
||||
if (models.length === 0) {
|
||||
modelsTableBody.innerHTML =
|
||||
'<tr><td colspan="4" class="text-center py-4">No models found in cache.</td></tr>';
|
||||
} else {
|
||||
models.forEach((model) => {
|
||||
const row = `
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">${model.name}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap font-mono text-sm">${model.id}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">${model.modality}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">${model.context_length || "N/A"}</td>
|
||||
</tr>
|
||||
`;
|
||||
modelsTableBody.innerHTML += row;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
modelsTableBody.innerHTML =
|
||||
'<tr><td colspan="4" class="text-center py-4 text-red-500">Error loading models.</td></tr>';
|
||||
console.error("Error fetching models:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCache() {
|
||||
refreshButton.disabled = true;
|
||||
refreshStatus.textContent = "Refreshing...";
|
||||
refreshStatus.classList.remove("text-red-500", "text-green-500");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/v1/admin/models/refresh", {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || "Failed to refresh cache");
|
||||
}
|
||||
refreshStatus.textContent = `Successfully refreshed ${data.refreshed} models. Total: ${data.total_models}.`;
|
||||
refreshStatus.classList.add("text-green-500");
|
||||
fetchCacheStatus();
|
||||
fetchModels();
|
||||
} catch (error) {
|
||||
refreshStatus.textContent = `Error: ${error.message}`;
|
||||
refreshStatus.classList.add("text-red-500");
|
||||
} finally {
|
||||
refreshButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
fetchCacheStatus();
|
||||
fetchModels();
|
||||
refreshButton.addEventListener("click", refreshCache);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,182 +1,182 @@
|
||||
{% extends "base.html" %} {% block title %}Admin - Video Jobs{% endblock %} {%
|
||||
block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-6">Admin: Video Jobs</h1>
|
||||
|
||||
<!-- Purge Old Jobs -->
|
||||
<div class="bg-gray-800 p-4 rounded-lg shadow-md mb-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Maintenance</h2>
|
||||
<p class="text-gray-400 mb-4">
|
||||
Delete all completed, failed, or cancelled jobs older than 30 days.
|
||||
</p>
|
||||
<button
|
||||
id="purge-button"
|
||||
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Purge Old Jobs
|
||||
</button>
|
||||
<p id="purge-status" class="mt-2 text-sm"></p>
|
||||
</div>
|
||||
|
||||
<!-- Video Jobs Table -->
|
||||
<div class="bg-gray-800 p-4 rounded-lg shadow-md overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-700">
|
||||
<thead class="bg-gray-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
User
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
Model
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
Prompt
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
Created
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jobs-table-body" class="bg-gray-800 divide-y divide-gray-700">
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-4">Loading jobs...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const jobsTableBody = document.getElementById("jobs-table-body");
|
||||
const purgeButton = document.getElementById("purge-button");
|
||||
const purgeStatus = document.getElementById("purge-status");
|
||||
|
||||
async function fetchJobs() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"{{ config['BACKEND_URL'] }}/admin/videos",
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer {{ session['access_token'] }}",
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to fetch jobs");
|
||||
const jobs = await response.json();
|
||||
jobsTableBody.innerHTML = "";
|
||||
if (jobs.length === 0) {
|
||||
jobsTableBody.innerHTML =
|
||||
'<tr><td colspan="6" class="text-center py-4">No video jobs found.</td></tr>';
|
||||
} else {
|
||||
jobs.forEach((job) => {
|
||||
const statusClass =
|
||||
job.status === "completed"
|
||||
? "text-green-400"
|
||||
: job.status === "failed" || job.status === "cancelled"
|
||||
? "text-red-400"
|
||||
: "text-yellow-400";
|
||||
const cancelBtn =
|
||||
job.status === "queued" || job.status === "processing"
|
||||
? `<button class="cancel-btn text-red-400 hover:text-red-600 text-sm" data-job-id="${job.id}">Cancel</button>`
|
||||
: "";
|
||||
const row = `
|
||||
<tr>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">${job.user_email || "Unknown"}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-semibold ${statusClass}">${job.status}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">${job.model_id}</td>
|
||||
<td class="px-4 py-3 text-sm truncate max-w-xs">${job.prompt}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">${new Date(job.created_at).toLocaleString()}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">${cancelBtn}</td>
|
||||
</tr>
|
||||
`;
|
||||
jobsTableBody.innerHTML += row;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
jobsTableBody.innerHTML =
|
||||
'<tr><td colspan="6" class="text-center py-4 text-red-500">Error loading jobs.</td></tr>';
|
||||
console.error("Error fetching jobs:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeJobs() {
|
||||
purgeButton.disabled = true;
|
||||
purgeStatus.textContent = "Purging...";
|
||||
purgeStatus.classList.remove("text-red-500", "text-green-500");
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"{{ config['BACKEND_URL'] }}/admin/videos/purge",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer {{ session['access_token'] }}",
|
||||
},
|
||||
},
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(data.detail || "Failed to purge jobs");
|
||||
purgeStatus.textContent = `Purged ${data.deleted} jobs. ${data.remaining} remaining.`;
|
||||
purgeStatus.classList.add("text-green-500");
|
||||
fetchJobs();
|
||||
} catch (error) {
|
||||
purgeStatus.textContent = `Error: ${error.message}`;
|
||||
purgeStatus.classList.add("text-red-500");
|
||||
} finally {
|
||||
purgeButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel button event delegation
|
||||
jobsTableBody.addEventListener("click", async function (e) {
|
||||
if (e.target.classList.contains("cancel-btn")) {
|
||||
const jobId = e.target.dataset.jobId;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`{{ config['BACKEND_URL'] }}/admin/videos/${jobId}/cancel`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer {{ session['access_token'] }}",
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to cancel job");
|
||||
fetchJobs();
|
||||
} catch (error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
purgeButton.addEventListener("click", purgeJobs);
|
||||
fetchJobs();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %} {% block title %}Admin - Video Jobs{% endblock %} {%
|
||||
block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-6">Admin: Video Jobs</h1>
|
||||
|
||||
<!-- Purge Old Jobs -->
|
||||
<div class="bg-gray-800 p-4 rounded-lg shadow-md mb-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Maintenance</h2>
|
||||
<p class="text-gray-400 mb-4">
|
||||
Delete all completed, failed, or cancelled jobs older than 30 days.
|
||||
</p>
|
||||
<button
|
||||
id="purge-button"
|
||||
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Purge Old Jobs
|
||||
</button>
|
||||
<p id="purge-status" class="mt-2 text-sm"></p>
|
||||
</div>
|
||||
|
||||
<!-- Video Jobs Table -->
|
||||
<div class="bg-gray-800 p-4 rounded-lg shadow-md overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-700">
|
||||
<thead class="bg-gray-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
User
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
Model
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
Prompt
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
Created
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jobs-table-body" class="bg-gray-800 divide-y divide-gray-700">
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-4">Loading jobs...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const jobsTableBody = document.getElementById("jobs-table-body");
|
||||
const purgeButton = document.getElementById("purge-button");
|
||||
const purgeStatus = document.getElementById("purge-status");
|
||||
|
||||
async function fetchJobs() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"{{ config['BACKEND_URL'] }}/admin/videos",
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer {{ session['access_token'] }}",
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to fetch jobs");
|
||||
const jobs = await response.json();
|
||||
jobsTableBody.innerHTML = "";
|
||||
if (jobs.length === 0) {
|
||||
jobsTableBody.innerHTML =
|
||||
'<tr><td colspan="6" class="text-center py-4">No video jobs found.</td></tr>';
|
||||
} else {
|
||||
jobs.forEach((job) => {
|
||||
const statusClass =
|
||||
job.status === "completed"
|
||||
? "text-green-400"
|
||||
: job.status === "failed" || job.status === "cancelled"
|
||||
? "text-red-400"
|
||||
: "text-yellow-400";
|
||||
const cancelBtn =
|
||||
job.status === "queued" || job.status === "processing"
|
||||
? `<button class="cancel-btn text-red-400 hover:text-red-600 text-sm" data-job-id="${job.id}">Cancel</button>`
|
||||
: "";
|
||||
const row = `
|
||||
<tr>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">${job.user_email || "Unknown"}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-semibold ${statusClass}">${job.status}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">${job.model_id}</td>
|
||||
<td class="px-4 py-3 text-sm truncate max-w-xs">${job.prompt}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">${new Date(job.created_at).toLocaleString()}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">${cancelBtn}</td>
|
||||
</tr>
|
||||
`;
|
||||
jobsTableBody.innerHTML += row;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
jobsTableBody.innerHTML =
|
||||
'<tr><td colspan="6" class="text-center py-4 text-red-500">Error loading jobs.</td></tr>';
|
||||
console.error("Error fetching jobs:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeJobs() {
|
||||
purgeButton.disabled = true;
|
||||
purgeStatus.textContent = "Purging...";
|
||||
purgeStatus.classList.remove("text-red-500", "text-green-500");
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"{{ config['BACKEND_URL'] }}/admin/videos/purge",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer {{ session['access_token'] }}",
|
||||
},
|
||||
},
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(data.detail || "Failed to purge jobs");
|
||||
purgeStatus.textContent = `Purged ${data.deleted} jobs. ${data.remaining} remaining.`;
|
||||
purgeStatus.classList.add("text-green-500");
|
||||
fetchJobs();
|
||||
} catch (error) {
|
||||
purgeStatus.textContent = `Error: ${error.message}`;
|
||||
purgeStatus.classList.add("text-red-500");
|
||||
} finally {
|
||||
purgeButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel button event delegation
|
||||
jobsTableBody.addEventListener("click", async function (e) {
|
||||
if (e.target.classList.contains("cancel-btn")) {
|
||||
const jobId = e.target.dataset.jobId;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`{{ config['BACKEND_URL'] }}/admin/videos/${jobId}/cancel`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer {{ session['access_token'] }}",
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to cancel job");
|
||||
fetchJobs();
|
||||
} catch (error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
purgeButton.addEventListener("click", purgeJobs);
|
||||
fetchJobs();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,60 +1,60 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}All You Can GET AI{% endblock %}</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('static', filename='style.css') }}"
|
||||
/>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="{{ url_for('auth.index') }}" class="brand"
|
||||
>All You Can GET AI</a
|
||||
>
|
||||
|
||||
<button class="hamburger" aria-label="Open menu">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
|
||||
<div class="nav-links">
|
||||
{% if session.get('access_token') %}
|
||||
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
||||
<a href="{{ url_for('gallery.index') }}">Gallery</a>
|
||||
|
||||
<a href="{{ url_for('generate.text') }}">Generate Text</a>
|
||||
<a href="{{ url_for('generate.image') }}">Generate Image</a>
|
||||
<a href="{{ url_for('generate.video') }}">Generate Video</a>
|
||||
|
||||
<a href="{{ url_for('profile.index') }}">Profile</a>
|
||||
{% if session.get('user_role') == 'admin' %}
|
||||
<a href="{{ url_for('admin.index') }}">Admin</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('auth.logout') }}">Log out</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}">Log in</a>
|
||||
<a href="{{ url_for('auth.register') }}">Register</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div id="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<span class="spinner-label">Working…</span>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %} {% for
|
||||
category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %} {% endwith %} {% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}All You Can GET AI{% endblock %}</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('static', filename='style.css') }}"
|
||||
/>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="{{ url_for('auth.index') }}" class="brand"
|
||||
>All You Can GET AI</a
|
||||
>
|
||||
|
||||
<button class="hamburger" aria-label="Open menu">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
|
||||
<div class="nav-links">
|
||||
{% if session.get('access_token') %}
|
||||
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
||||
<a href="{{ url_for('gallery.index') }}">Gallery</a>
|
||||
|
||||
<a href="{{ url_for('generate.text') }}">Generate Text</a>
|
||||
<a href="{{ url_for('generate.image') }}">Generate Image</a>
|
||||
<a href="{{ url_for('generate.video') }}">Generate Video</a>
|
||||
|
||||
<a href="{{ url_for('profile.index') }}">Profile</a>
|
||||
{% if session.get('user_role') == 'admin' %}
|
||||
<a href="{{ url_for('admin.index') }}">Admin</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('auth.logout') }}">Log out</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}">Log in</a>
|
||||
<a href="{{ url_for('auth.register') }}">Register</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div id="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<span class="spinner-label">Working…</span>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %} {% for
|
||||
category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %} {% endwith %} {% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,116 +1,116 @@
|
||||
{% extends "base.html" %} {% block title %}Dashboard — All You Can GET AI{%
|
||||
endblock %} {% block content %}
|
||||
<div class="card">
|
||||
<h1>Welcome{% if user.get('email') %}, {{ user.email }}{% endif %}</h1>
|
||||
<p>Role: <strong>{{ user.get('role', 'user') }}</strong></p>
|
||||
<a href="{{ url_for('generate.index') }}" class="btn">Start generating</a>
|
||||
</div>
|
||||
|
||||
{% if pending_videos %}
|
||||
<div class="card mt-2">
|
||||
<h2>Pending Video Jobs</h2>
|
||||
<div class="image-grid">
|
||||
{% for vid in pending_videos %}
|
||||
<a
|
||||
href="{{ url_for('gallery.video_detail', video_id=vid.id) }}"
|
||||
class="image-grid-item"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
background: #1a1a1a;
|
||||
border-radius: 6px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<span class="text-muted">{{ vid.status | capitalize }} …</span>
|
||||
</div>
|
||||
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
|
||||
<strong>{{ vid.model_id }}</strong><br />{{ vid.prompt[:80] }}{% if
|
||||
vid.prompt|length > 80 %}…{% endif %}
|
||||
</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %} {% if generated_images %}
|
||||
<div class="card mt-2">
|
||||
<h2>Generated images</h2>
|
||||
<div class="image-grid">
|
||||
{% for img in generated_images %}
|
||||
<a
|
||||
href="{{ url_for('gallery.image_detail', image_id=img.id) }}"
|
||||
class="image-grid-item"
|
||||
>
|
||||
<img
|
||||
src="{{ img.image_data }}"
|
||||
alt="{{ img.prompt }}"
|
||||
class="generated-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
|
||||
<strong>{{ img.model_id }}</strong><br />{{ img.prompt[:80] }}{% if
|
||||
img.prompt|length > 80 %}…{% endif %}
|
||||
</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %} {% if completed_videos %}
|
||||
<div class="card mt-2">
|
||||
<h2>Generated videos</h2>
|
||||
<div class="image-grid">
|
||||
{% for vid in completed_videos %}
|
||||
<a
|
||||
href="{{ url_for('gallery.video_detail', video_id=vid.id) }}"
|
||||
class="image-grid-item"
|
||||
>
|
||||
{% if vid.video_url %}
|
||||
<video controls style="max-width: 100%; border-radius: 6px">
|
||||
<source src="{{ vid.video_url }}" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
{% else %}
|
||||
<div
|
||||
style="
|
||||
background: #1a1a1a;
|
||||
border-radius: 6px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<span class="text-muted">{{ vid.status | capitalize }} …</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
|
||||
<strong>{{ vid.model_id }}</strong><br />{{ vid.prompt[:80] }}{% if
|
||||
vid.prompt|length > 80 %}…{% endif %}<br />
|
||||
<em>{{ vid.status }}</em>
|
||||
</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %} {% if images %}
|
||||
<div class="card mt-2">
|
||||
<h2>Uploaded reference images</h2>
|
||||
<div class="image-grid">
|
||||
{% for img in images %}
|
||||
<a
|
||||
href="{{ url_for('gallery.upload_detail', image_id=img.id) }}"
|
||||
class="image-grid-item"
|
||||
>
|
||||
<img
|
||||
src="{{ url_for('gallery.serve_uploaded_image', image_id=img.id) }}"
|
||||
alt="{{ img.filename }}"
|
||||
class="generated-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
|
||||
{{ img.filename }} — {{ (img.size_bytes / 1024) | round(1) }} KB
|
||||
</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %} {% endblock %}
|
||||
{% extends "base.html" %} {% block title %}Dashboard — All You Can GET AI{%
|
||||
endblock %} {% block content %}
|
||||
<div class="card">
|
||||
<h1>Welcome{% if user.get('email') %}, {{ user.email }}{% endif %}</h1>
|
||||
<p>Role: <strong>{{ user.get('role', 'user') }}</strong></p>
|
||||
<a href="{{ url_for('generate.index') }}" class="btn">Start generating</a>
|
||||
</div>
|
||||
|
||||
{% if pending_videos %}
|
||||
<div class="card mt-2">
|
||||
<h2>Pending Video Jobs</h2>
|
||||
<div class="image-grid">
|
||||
{% for vid in pending_videos %}
|
||||
<a
|
||||
href="{{ url_for('gallery.video_detail', video_id=vid.id) }}"
|
||||
class="image-grid-item"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
background: #1a1a1a;
|
||||
border-radius: 6px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<span class="text-muted">{{ vid.status | capitalize }} …</span>
|
||||
</div>
|
||||
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
|
||||
<strong>{{ vid.model_id }}</strong><br />{{ vid.prompt[:80] }}{% if
|
||||
vid.prompt|length > 80 %}…{% endif %}
|
||||
</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %} {% if generated_images %}
|
||||
<div class="card mt-2">
|
||||
<h2>Generated images</h2>
|
||||
<div class="image-grid">
|
||||
{% for img in generated_images %}
|
||||
<a
|
||||
href="{{ url_for('gallery.image_detail', image_id=img.id) }}"
|
||||
class="image-grid-item"
|
||||
>
|
||||
<img
|
||||
src="{{ img.image_data }}"
|
||||
alt="{{ img.prompt }}"
|
||||
class="generated-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
|
||||
<strong>{{ img.model_id }}</strong><br />{{ img.prompt[:80] }}{% if
|
||||
img.prompt|length > 80 %}…{% endif %}
|
||||
</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %} {% if completed_videos %}
|
||||
<div class="card mt-2">
|
||||
<h2>Generated videos</h2>
|
||||
<div class="image-grid">
|
||||
{% for vid in completed_videos %}
|
||||
<a
|
||||
href="{{ url_for('gallery.video_detail', video_id=vid.id) }}"
|
||||
class="image-grid-item"
|
||||
>
|
||||
{% if vid.video_url %}
|
||||
<video controls style="max-width: 100%; border-radius: 6px">
|
||||
<source src="{{ vid.video_url }}" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
{% else %}
|
||||
<div
|
||||
style="
|
||||
background: #1a1a1a;
|
||||
border-radius: 6px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<span class="text-muted">{{ vid.status | capitalize }} …</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
|
||||
<strong>{{ vid.model_id }}</strong><br />{{ vid.prompt[:80] }}{% if
|
||||
vid.prompt|length > 80 %}…{% endif %}<br />
|
||||
<em>{{ vid.status }}</em>
|
||||
</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %} {% if images %}
|
||||
<div class="card mt-2">
|
||||
<h2>Uploaded reference images</h2>
|
||||
<div class="image-grid">
|
||||
{% for img in images %}
|
||||
<a
|
||||
href="{{ url_for('gallery.upload_detail', image_id=img.id) }}"
|
||||
class="image-grid-item"
|
||||
>
|
||||
<img
|
||||
src="{{ url_for('gallery.serve_uploaded_image', image_id=img.id) }}"
|
||||
alt="{{ img.filename }}"
|
||||
class="generated-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
|
||||
{{ img.filename }} — {{ (img.size_bytes / 1024) | round(1) }} KB
|
||||
</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %} {% endblock %}
|
||||
+300
-300
@@ -1,300 +1,300 @@
|
||||
{% extends "base.html" %} {% block title %}My Gallery{% endblock %} {% block
|
||||
content %}
|
||||
<div
|
||||
class="container mx-auto px-4 py-8"
|
||||
data-current-page="1"
|
||||
data-per-page="12"
|
||||
>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-6">My Gallery</h1>
|
||||
|
||||
<!-- Pending Creations -->
|
||||
{% if pending_videos %}
|
||||
<div class="mb-12">
|
||||
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
|
||||
Pending Creations
|
||||
</h2>
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
>
|
||||
{% for video in pending_videos %}
|
||||
<div
|
||||
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300 relative"
|
||||
data-pending-video-id="{{ video.id }}"
|
||||
>
|
||||
<a href="{{ url_for('gallery.video_detail', video_id=video.id) }}">
|
||||
<div class="p-4">
|
||||
<p class="font-bold text-lg truncate">{{ video.prompt }}</p>
|
||||
<p class="text-sm text-gray-400">
|
||||
Video Job Status:
|
||||
<span class="font-semibold text-yellow-400"
|
||||
>{{ video.status }}</span
|
||||
>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
Started: {{ video.created_at | fromisoformat | humantime }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<div class="px-4 pb-4">
|
||||
<button
|
||||
class="cancel-pending-btn px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-xs"
|
||||
data-video-id="{{ video.id }}"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<span class="cancel-pending-msg text-xs ml-2 hidden"></span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Generated Images -->
|
||||
<div class="mb-12">
|
||||
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
|
||||
Generated Images
|
||||
</h2>
|
||||
{% if generated_images %}
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
>
|
||||
{% for image in generated_images %}
|
||||
<a
|
||||
href="{{ url_for('gallery.image_detail', image_id=image.id) }}"
|
||||
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
|
||||
>
|
||||
<img
|
||||
src="{{ image.image_data }}"
|
||||
alt="{{ image.prompt }}"
|
||||
class="w-full h-48 object-cover"
|
||||
/>
|
||||
<div class="p-4">
|
||||
<p class="font-bold text-sm truncate">{{ image.prompt }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Image ID: {{ image.id[:8] }}...
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{{ image.created_at | fromisoformat | humantime }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-400">
|
||||
You haven't generated any images yet.
|
||||
<a
|
||||
href="{{ url_for('generate.image') }}"
|
||||
class="text-blue-400 hover:underline"
|
||||
>Generate one now</a
|
||||
>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Generated Videos -->
|
||||
<div class="mb-12">
|
||||
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
|
||||
Generated Videos
|
||||
</h2>
|
||||
{% if completed_videos %}
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
>
|
||||
{% for video in completed_videos %}
|
||||
<a
|
||||
href="{{ url_for('gallery.video_detail', video_id=video.id) }}"
|
||||
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
|
||||
>
|
||||
{% if video.video_url %}
|
||||
<img
|
||||
src="{{ video.video_url }}#t=0.1"
|
||||
alt="{{ video.prompt }}"
|
||||
class="w-full h-48 object-cover"
|
||||
/>
|
||||
{% else %}
|
||||
<div class="w-full h-48 bg-black flex items-center justify-center">
|
||||
<svg
|
||||
class="w-12 h-12 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="p-4">
|
||||
<p class="font-bold text-sm truncate">{{ video.prompt }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Video ID: {{ video.id[:8] }}...
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{{ video.created_at | fromisoformat | humantime }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-400">
|
||||
You haven't generated any videos yet.
|
||||
<a
|
||||
href="{{ url_for('generate.video') }}"
|
||||
class="text-blue-400 hover:underline"
|
||||
>Generate one now</a
|
||||
>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Uploaded Images -->
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
|
||||
My Uploads
|
||||
</h2>
|
||||
{% if uploads %}
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
>
|
||||
{% for image in uploads %}
|
||||
<a
|
||||
href="{{ url_for('gallery.upload_detail', image_id=image.id) }}"
|
||||
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
|
||||
>
|
||||
<img
|
||||
src="{{ url_for('gallery.serve_uploaded_image', image_id=image.id) }}"
|
||||
alt="{{ image.filename }}"
|
||||
class="w-full h-48 object-cover"
|
||||
/>
|
||||
<div class="p-4">
|
||||
<p class="font-bold text-sm truncate">{{ image.filename }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Upload ID: {{ image.id[:8] }}...
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{{ image.uploaded_at | fromisoformat | humantime }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-400">You haven't uploaded any images.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Infinite Scroll Loading Indicator -->
|
||||
<div id="loading-indicator" class="flex justify-center py-8 hidden">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const galleryContainers = document.querySelectorAll(".grid[data-grid]");
|
||||
const loadingIndicator = document.getElementById("loading-indicator");
|
||||
const container = document.querySelector(".container[data-current-page]");
|
||||
const currentPage = parseInt(container.dataset.currentPage);
|
||||
const perPage = parseInt(container.dataset.perPage);
|
||||
let isLoading = false;
|
||||
let hasMore = true;
|
||||
|
||||
// Add data-grid attribute to all gallery grids
|
||||
document
|
||||
.querySelectorAll(".grid")
|
||||
.forEach((grid) => grid.setAttribute("data-grid", ""));
|
||||
|
||||
// Infinite scroll handler
|
||||
window.addEventListener("scroll", async function () {
|
||||
if (!hasMore || isLoading) return;
|
||||
|
||||
const scrollPosition = window.innerHeight + window.scrollY;
|
||||
const bottomThreshold = document.body.offsetHeight - 1000;
|
||||
|
||||
if (scrollPosition >= bottomThreshold) {
|
||||
isLoading = true;
|
||||
loadingIndicator.classList.remove("hidden");
|
||||
// TODO: Implement actual fetching of next page of results and appending to the correct grid(s)
|
||||
// For demo purposes, we'll just simulate a delay and then hide the loading indicator
|
||||
// Simulate API call for next page
|
||||
// In real implementation, replace with actual backend fetch
|
||||
setTimeout(() => {
|
||||
isLoading = false;
|
||||
loadingIndicator.classList.add("hidden");
|
||||
// Real app would fetch /generate/images?page=${currentPage +1}&limit=${perPage}
|
||||
// and /generate/videos similarly
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
// Cancel pending video buttons
|
||||
document.querySelectorAll(".cancel-pending-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const videoId = btn.dataset.videoId;
|
||||
const msgEl = btn.parentElement.querySelector(".cancel-pending-msg");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Cancelling…";
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/generate/video/" + encodeURIComponent(videoId) + "/cancel",
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (resp.ok) {
|
||||
btn.classList.add("hidden");
|
||||
if (msgEl) {
|
||||
msgEl.textContent = "Cancelled";
|
||||
msgEl.classList.remove("hidden", "text-red-500");
|
||||
msgEl.classList.add("text-gray-300");
|
||||
}
|
||||
const card = document.querySelector(
|
||||
'[data-pending-video-id="' + videoId + '"]',
|
||||
);
|
||||
if (card) {
|
||||
const statusSpan = card.querySelector(".text-yellow-400");
|
||||
if (statusSpan) {
|
||||
statusSpan.textContent = "cancelled";
|
||||
statusSpan.classList.remove("text-yellow-400");
|
||||
statusSpan.classList.add("text-gray-400");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Cancel";
|
||||
if (msgEl) {
|
||||
msgEl.textContent = data.detail || "Failed";
|
||||
msgEl.classList.remove("hidden");
|
||||
msgEl.classList.add("text-red-500");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Cancel";
|
||||
if (msgEl) {
|
||||
msgEl.textContent = "Error";
|
||||
msgEl.classList.remove("hidden");
|
||||
msgEl.classList.add("text-red-500");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% extends "base.html" %} {% block title %}My Gallery{% endblock %} {% block
|
||||
content %}
|
||||
<div
|
||||
class="container mx-auto px-4 py-8"
|
||||
data-current-page="1"
|
||||
data-per-page="12"
|
||||
>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-6">My Gallery</h1>
|
||||
|
||||
<!-- Pending Creations -->
|
||||
{% if pending_videos %}
|
||||
<div class="mb-12">
|
||||
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
|
||||
Pending Creations
|
||||
</h2>
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
>
|
||||
{% for video in pending_videos %}
|
||||
<div
|
||||
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300 relative"
|
||||
data-pending-video-id="{{ video.id }}"
|
||||
>
|
||||
<a href="{{ url_for('gallery.video_detail', video_id=video.id) }}">
|
||||
<div class="p-4">
|
||||
<p class="font-bold text-lg truncate">{{ video.prompt }}</p>
|
||||
<p class="text-sm text-gray-400">
|
||||
Video Job Status:
|
||||
<span class="font-semibold text-yellow-400"
|
||||
>{{ video.status }}</span
|
||||
>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
Started: {{ video.created_at | fromisoformat | humantime }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<div class="px-4 pb-4">
|
||||
<button
|
||||
class="cancel-pending-btn px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-xs"
|
||||
data-video-id="{{ video.id }}"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<span class="cancel-pending-msg text-xs ml-2 hidden"></span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Generated Images -->
|
||||
<div class="mb-12">
|
||||
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
|
||||
Generated Images
|
||||
</h2>
|
||||
{% if generated_images %}
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
>
|
||||
{% for image in generated_images %}
|
||||
<a
|
||||
href="{{ url_for('gallery.image_detail', image_id=image.id) }}"
|
||||
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
|
||||
>
|
||||
<img
|
||||
src="{{ image.image_data }}"
|
||||
alt="{{ image.prompt }}"
|
||||
class="w-full h-48 object-cover"
|
||||
/>
|
||||
<div class="p-4">
|
||||
<p class="font-bold text-sm truncate">{{ image.prompt }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Image ID: {{ image.id[:8] }}...
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{{ image.created_at | fromisoformat | humantime }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-400">
|
||||
You haven't generated any images yet.
|
||||
<a
|
||||
href="{{ url_for('generate.image') }}"
|
||||
class="text-blue-400 hover:underline"
|
||||
>Generate one now</a
|
||||
>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Generated Videos -->
|
||||
<div class="mb-12">
|
||||
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
|
||||
Generated Videos
|
||||
</h2>
|
||||
{% if completed_videos %}
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
>
|
||||
{% for video in completed_videos %}
|
||||
<a
|
||||
href="{{ url_for('gallery.video_detail', video_id=video.id) }}"
|
||||
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
|
||||
>
|
||||
{% if video.video_url %}
|
||||
<img
|
||||
src="{{ video.video_url }}#t=0.1"
|
||||
alt="{{ video.prompt }}"
|
||||
class="w-full h-48 object-cover"
|
||||
/>
|
||||
{% else %}
|
||||
<div class="w-full h-48 bg-black flex items-center justify-center">
|
||||
<svg
|
||||
class="w-12 h-12 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="p-4">
|
||||
<p class="font-bold text-sm truncate">{{ video.prompt }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Video ID: {{ video.id[:8] }}...
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{{ video.created_at | fromisoformat | humantime }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-400">
|
||||
You haven't generated any videos yet.
|
||||
<a
|
||||
href="{{ url_for('generate.video') }}"
|
||||
class="text-blue-400 hover:underline"
|
||||
>Generate one now</a
|
||||
>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Uploaded Images -->
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
|
||||
My Uploads
|
||||
</h2>
|
||||
{% if uploads %}
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
>
|
||||
{% for image in uploads %}
|
||||
<a
|
||||
href="{{ url_for('gallery.upload_detail', image_id=image.id) }}"
|
||||
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
|
||||
>
|
||||
<img
|
||||
src="{{ url_for('gallery.serve_uploaded_image', image_id=image.id) }}"
|
||||
alt="{{ image.filename }}"
|
||||
class="w-full h-48 object-cover"
|
||||
/>
|
||||
<div class="p-4">
|
||||
<p class="font-bold text-sm truncate">{{ image.filename }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Upload ID: {{ image.id[:8] }}...
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{{ image.uploaded_at | fromisoformat | humantime }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-400">You haven't uploaded any images.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Infinite Scroll Loading Indicator -->
|
||||
<div id="loading-indicator" class="flex justify-center py-8 hidden">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const galleryContainers = document.querySelectorAll(".grid[data-grid]");
|
||||
const loadingIndicator = document.getElementById("loading-indicator");
|
||||
const container = document.querySelector(".container[data-current-page]");
|
||||
const currentPage = parseInt(container.dataset.currentPage);
|
||||
const perPage = parseInt(container.dataset.perPage);
|
||||
let isLoading = false;
|
||||
let hasMore = true;
|
||||
|
||||
// Add data-grid attribute to all gallery grids
|
||||
document
|
||||
.querySelectorAll(".grid")
|
||||
.forEach((grid) => grid.setAttribute("data-grid", ""));
|
||||
|
||||
// Infinite scroll handler
|
||||
window.addEventListener("scroll", async function () {
|
||||
if (!hasMore || isLoading) return;
|
||||
|
||||
const scrollPosition = window.innerHeight + window.scrollY;
|
||||
const bottomThreshold = document.body.offsetHeight - 1000;
|
||||
|
||||
if (scrollPosition >= bottomThreshold) {
|
||||
isLoading = true;
|
||||
loadingIndicator.classList.remove("hidden");
|
||||
// TODO: Implement actual fetching of next page of results and appending to the correct grid(s)
|
||||
// For demo purposes, we'll just simulate a delay and then hide the loading indicator
|
||||
// Simulate API call for next page
|
||||
// In real implementation, replace with actual backend fetch
|
||||
setTimeout(() => {
|
||||
isLoading = false;
|
||||
loadingIndicator.classList.add("hidden");
|
||||
// Real app would fetch /generate/images?page=${currentPage +1}&limit=${perPage}
|
||||
// and /generate/videos similarly
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
// Cancel pending video buttons
|
||||
document.querySelectorAll(".cancel-pending-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const videoId = btn.dataset.videoId;
|
||||
const msgEl = btn.parentElement.querySelector(".cancel-pending-msg");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Cancelling…";
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/generate/video/" + encodeURIComponent(videoId) + "/cancel",
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (resp.ok) {
|
||||
btn.classList.add("hidden");
|
||||
if (msgEl) {
|
||||
msgEl.textContent = "Cancelled";
|
||||
msgEl.classList.remove("hidden", "text-red-500");
|
||||
msgEl.classList.add("text-gray-300");
|
||||
}
|
||||
const card = document.querySelector(
|
||||
'[data-pending-video-id="' + videoId + '"]',
|
||||
);
|
||||
if (card) {
|
||||
const statusSpan = card.querySelector(".text-yellow-400");
|
||||
if (statusSpan) {
|
||||
statusSpan.textContent = "cancelled";
|
||||
statusSpan.classList.remove("text-yellow-400");
|
||||
statusSpan.classList.add("text-gray-400");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Cancel";
|
||||
if (msgEl) {
|
||||
msgEl.textContent = data.detail || "Failed";
|
||||
msgEl.classList.remove("hidden");
|
||||
msgEl.classList.add("text-red-500");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Cancel";
|
||||
if (msgEl) {
|
||||
msgEl.textContent = "Error";
|
||||
msgEl.classList.remove("hidden");
|
||||
msgEl.classList.add("text-red-500");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
</div>
|
||||
@@ -1,9 +1,9 @@
|
||||
{% extends "base.html" %} {% block title %}Generate — All You Can GET AI{%
|
||||
endblock %} {% block content %}
|
||||
<div class="card">
|
||||
<h1>Generate</h1>
|
||||
<p class="text-muted">
|
||||
Choose a generation type from the Generate menu above.
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %} {% block title %}Generate — All You Can GET AI{%
|
||||
endblock %} {% block content %}
|
||||
<div class="card">
|
||||
<h1>Generate</h1>
|
||||
<p class="text-muted">
|
||||
Choose a generation type from the Generate menu above.
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,82 +1,82 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Image Generation — All You Can GET AI{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<h1>Image Generation</h1>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<label for="model">Model</label>
|
||||
{% if models %}
|
||||
<select id="model" name="model" required>
|
||||
{% for m in models %}
|
||||
<option value="{{ m.id }}" {{ "selected" if request.form.get('model', '') == m.id else "" }}>{{ m.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input id="model" name="model" type="text" required
|
||||
placeholder="e.g. google/gemini-2.5-flash-image"
|
||||
value="{{ request.form.get('model', '') }}">
|
||||
<p class="text-muted mt-1">No models available</p>
|
||||
{% endif %}
|
||||
|
||||
<label for="prompt">Prompt</label>
|
||||
<textarea id="prompt" name="prompt" rows="4" required
|
||||
placeholder="Describe the image you want…">{{ request.form.get('prompt', '') }}</textarea>
|
||||
|
||||
<label for="aspect_ratio">Aspect ratio</label>
|
||||
<select id="aspect_ratio" name="aspect_ratio">
|
||||
<option value="">Auto (default 1:1)</option>
|
||||
<option value="1:1" {{ "selected" if request.form.get('aspect_ratio')=='1:1' else "" }}>1:1 (square)</option>
|
||||
<option value="16:9" {{ "selected" if request.form.get('aspect_ratio')=='16:9' else "" }}>16:9 (landscape)</option>
|
||||
<option value="9:16" {{ "selected" if request.form.get('aspect_ratio')=='9:16' else "" }}>9:16 (portrait)</option>
|
||||
<option value="4:3" {{ "selected" if request.form.get('aspect_ratio')=='4:3' else "" }}>4:3</option>
|
||||
<option value="3:4" {{ "selected" if request.form.get('aspect_ratio')=='3:4' else "" }}>3:4</option>
|
||||
<option value="3:2" {{ "selected" if request.form.get('aspect_ratio')=='3:2' else "" }}>3:2</option>
|
||||
<option value="2:3" {{ "selected" if request.form.get('aspect_ratio')=='2:3' else "" }}>2:3</option>
|
||||
</select>
|
||||
|
||||
<label for="image_size">Resolution</label>
|
||||
<select id="image_size" name="image_size">
|
||||
<option value="">Auto (default 1K)</option>
|
||||
<option value="0.5K" {{ "selected" if request.form.get('image_size')=='0.5K' else "" }}>0.5K (low)</option>
|
||||
<option value="1K" {{ "selected" if request.form.get('image_size')=='1K' else "" }}>1K (standard)</option>
|
||||
<option value="2K" {{ "selected" if request.form.get('image_size')=='2K' else "" }}>2K (high)</option>
|
||||
<option value="4K" {{ "selected" if request.form.get('image_size')=='4K' else "" }}>4K (ultra)</option>
|
||||
</select>
|
||||
|
||||
<label for="reference_image">Reference image (optional)</label>
|
||||
<input
|
||||
id="reference_image"
|
||||
name="reference_image"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
>
|
||||
<p class="text-muted mt-1" id="reference-image-help">
|
||||
Upload an image to use as visual reference (image-to-image).
|
||||
</p>
|
||||
<div class="image-upload-preview" id="image-upload-preview" hidden>
|
||||
<p class="text-muted" id="image-upload-filename"></p>
|
||||
<img id="image-upload-preview-img" alt="Uploaded reference image preview" class="generated-image">
|
||||
</div>
|
||||
|
||||
<button type="submit">Generate image</button>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error mt-2">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result %}
|
||||
<div class="result">
|
||||
<h2>Generated image{{ 's' if result.images|length > 1 }}</h2>
|
||||
{% for img in result.images %}
|
||||
{% if img.url %}
|
||||
<img src="{{ img.url }}" alt="Generated image" class="generated-image">
|
||||
{% endif %}
|
||||
{% if img.revised_prompt %}
|
||||
<p class="text-muted mt-1" style="font-size:0.8rem;">{{ img.revised_prompt }}</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Image Generation — All You Can GET AI{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<h1>Image Generation</h1>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<label for="model">Model</label>
|
||||
{% if models %}
|
||||
<select id="model" name="model" required>
|
||||
{% for m in models %}
|
||||
<option value="{{ m.id }}" {{ "selected" if request.form.get('model', '') == m.id else "" }}>{{ m.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input id="model" name="model" type="text" required
|
||||
placeholder="e.g. google/gemini-2.5-flash-image"
|
||||
value="{{ request.form.get('model', '') }}">
|
||||
<p class="text-muted mt-1">No models available</p>
|
||||
{% endif %}
|
||||
|
||||
<label for="prompt">Prompt</label>
|
||||
<textarea id="prompt" name="prompt" rows="4" required
|
||||
placeholder="Describe the image you want…">{{ request.form.get('prompt', '') }}</textarea>
|
||||
|
||||
<label for="aspect_ratio">Aspect ratio</label>
|
||||
<select id="aspect_ratio" name="aspect_ratio">
|
||||
<option value="">Auto (default 1:1)</option>
|
||||
<option value="1:1" {{ "selected" if request.form.get('aspect_ratio')=='1:1' else "" }}>1:1 (square)</option>
|
||||
<option value="16:9" {{ "selected" if request.form.get('aspect_ratio')=='16:9' else "" }}>16:9 (landscape)</option>
|
||||
<option value="9:16" {{ "selected" if request.form.get('aspect_ratio')=='9:16' else "" }}>9:16 (portrait)</option>
|
||||
<option value="4:3" {{ "selected" if request.form.get('aspect_ratio')=='4:3' else "" }}>4:3</option>
|
||||
<option value="3:4" {{ "selected" if request.form.get('aspect_ratio')=='3:4' else "" }}>3:4</option>
|
||||
<option value="3:2" {{ "selected" if request.form.get('aspect_ratio')=='3:2' else "" }}>3:2</option>
|
||||
<option value="2:3" {{ "selected" if request.form.get('aspect_ratio')=='2:3' else "" }}>2:3</option>
|
||||
</select>
|
||||
|
||||
<label for="image_size">Resolution</label>
|
||||
<select id="image_size" name="image_size">
|
||||
<option value="">Auto (default 1K)</option>
|
||||
<option value="0.5K" {{ "selected" if request.form.get('image_size')=='0.5K' else "" }}>0.5K (low)</option>
|
||||
<option value="1K" {{ "selected" if request.form.get('image_size')=='1K' else "" }}>1K (standard)</option>
|
||||
<option value="2K" {{ "selected" if request.form.get('image_size')=='2K' else "" }}>2K (high)</option>
|
||||
<option value="4K" {{ "selected" if request.form.get('image_size')=='4K' else "" }}>4K (ultra)</option>
|
||||
</select>
|
||||
|
||||
<label for="reference_image">Reference image (optional)</label>
|
||||
<input
|
||||
id="reference_image"
|
||||
name="reference_image"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
>
|
||||
<p class="text-muted mt-1" id="reference-image-help">
|
||||
Upload an image to use as visual reference (image-to-image).
|
||||
</p>
|
||||
<div class="image-upload-preview" id="image-upload-preview" hidden>
|
||||
<p class="text-muted" id="image-upload-filename"></p>
|
||||
<img id="image-upload-preview-img" alt="Uploaded reference image preview" class="generated-image">
|
||||
</div>
|
||||
|
||||
<button type="submit">Generate image</button>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error mt-2">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result %}
|
||||
<div class="result">
|
||||
<h2>Generated image{{ 's' if result.images|length > 1 }}</h2>
|
||||
{% for img in result.images %}
|
||||
{% if img.url %}
|
||||
<img src="{{ img.url }}" alt="Generated image" class="generated-image">
|
||||
{% endif %}
|
||||
{% if img.revised_prompt %}
|
||||
<p class="text-muted mt-1" style="font-size:0.8rem;">{{ img.revised_prompt }}</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,93 +1,93 @@
|
||||
{% extends "base.html" %} {% block title %}Text Generation — All You Can GET
|
||||
AI{% endblock %} {% block content %}
|
||||
<div class="card chat-page">
|
||||
<div class="chat-header">
|
||||
<h1>Text Chat</h1>
|
||||
<form method="post" style="display: inline">
|
||||
<input type="hidden" name="action" value="clear" />
|
||||
<button type="submit" class="btn-secondary btn-sm">New Chat</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Config row -->
|
||||
<details class="chat-config" {% if not chat_history %}open{% endif %}>
|
||||
<summary>Model & System Prompt</summary>
|
||||
<div class="chat-config-body">
|
||||
<label for="cfg-model">Model</label>
|
||||
{% if models %}
|
||||
<select id="cfg-model" form="chat-form" name="model" required>
|
||||
{% for m in models %}
|
||||
<option value="{{ m.id }}" {{ "selected" if current_model == m.id else "" }}>{{ m.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input
|
||||
id="cfg-model"
|
||||
form="chat-form"
|
||||
name="model"
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. openai/gpt-4o"
|
||||
value="{{ current_model }}"
|
||||
/>
|
||||
<p class="text-muted mt-1">No models available</p>
|
||||
{% endif %}
|
||||
|
||||
<label for="cfg-sys">System prompt (optional)</label>
|
||||
<textarea
|
||||
id="cfg-sys"
|
||||
form="chat-form"
|
||||
name="system_prompt"
|
||||
rows="2"
|
||||
placeholder="Set behavior/instructions for assistant…"
|
||||
>
|
||||
{{ system_prompt }}</textarea
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Chat history -->
|
||||
<div class="chat-history" id="chat-history">
|
||||
{% if not chat_history %}
|
||||
<p class="chat-empty">No messages yet. Start the conversation below.</p>
|
||||
{% endif %} {% for msg in chat_history %} {% if msg.role == "user" %}
|
||||
<div class="chat-bubble chat-bubble--user">
|
||||
<span class="bubble-role">You</span>
|
||||
<div class="bubble-content">{{ msg.content }}</div>
|
||||
</div>
|
||||
{% elif msg.role == "assistant" %}
|
||||
<div class="chat-bubble chat-bubble--assistant">
|
||||
<span class="bubble-role">Assistant</span>
|
||||
<div class="bubble-content">{{ msg.content }}</div>
|
||||
{% if msg.usage %}
|
||||
<span class="bubble-meta"
|
||||
>{{ msg.usage.get('total_tokens', '') }} tokens</span
|
||||
>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %} {% endfor %} {% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<form id="chat-form" method="post" class="chat-input-row">
|
||||
<input type="hidden" name="action" value="send" />
|
||||
<textarea
|
||||
name="prompt"
|
||||
id="prompt"
|
||||
rows="2"
|
||||
required
|
||||
placeholder="Type a message…"
|
||||
class="chat-input-textarea"
|
||||
></textarea>
|
||||
<button type="submit" class="btn-primary">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-scroll chat to bottom
|
||||
const hist = document.getElementById("chat-history");
|
||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %} {% block title %}Text Generation — All You Can GET
|
||||
AI{% endblock %} {% block content %}
|
||||
<div class="card chat-page">
|
||||
<div class="chat-header">
|
||||
<h1>Text Chat</h1>
|
||||
<form method="post" style="display: inline">
|
||||
<input type="hidden" name="action" value="clear" />
|
||||
<button type="submit" class="btn-secondary btn-sm">New Chat</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Config row -->
|
||||
<details class="chat-config" {% if not chat_history %}open{% endif %}>
|
||||
<summary>Model & System Prompt</summary>
|
||||
<div class="chat-config-body">
|
||||
<label for="cfg-model">Model</label>
|
||||
{% if models %}
|
||||
<select id="cfg-model" form="chat-form" name="model" required>
|
||||
{% for m in models %}
|
||||
<option value="{{ m.id }}" {{ "selected" if current_model == m.id else "" }}>{{ m.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input
|
||||
id="cfg-model"
|
||||
form="chat-form"
|
||||
name="model"
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. openai/gpt-4o"
|
||||
value="{{ current_model }}"
|
||||
/>
|
||||
<p class="text-muted mt-1">No models available</p>
|
||||
{% endif %}
|
||||
|
||||
<label for="cfg-sys">System prompt (optional)</label>
|
||||
<textarea
|
||||
id="cfg-sys"
|
||||
form="chat-form"
|
||||
name="system_prompt"
|
||||
rows="2"
|
||||
placeholder="Set behavior/instructions for assistant…"
|
||||
>
|
||||
{{ system_prompt }}</textarea
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Chat history -->
|
||||
<div class="chat-history" id="chat-history">
|
||||
{% if not chat_history %}
|
||||
<p class="chat-empty">No messages yet. Start the conversation below.</p>
|
||||
{% endif %} {% for msg in chat_history %} {% if msg.role == "user" %}
|
||||
<div class="chat-bubble chat-bubble--user">
|
||||
<span class="bubble-role">You</span>
|
||||
<div class="bubble-content">{{ msg.content }}</div>
|
||||
</div>
|
||||
{% elif msg.role == "assistant" %}
|
||||
<div class="chat-bubble chat-bubble--assistant">
|
||||
<span class="bubble-role">Assistant</span>
|
||||
<div class="bubble-content">{{ msg.content }}</div>
|
||||
{% if msg.usage %}
|
||||
<span class="bubble-meta"
|
||||
>{{ msg.usage.get('total_tokens', '') }} tokens</span
|
||||
>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %} {% endfor %} {% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<form id="chat-form" method="post" class="chat-input-row">
|
||||
<input type="hidden" name="action" value="send" />
|
||||
<textarea
|
||||
name="prompt"
|
||||
id="prompt"
|
||||
rows="2"
|
||||
required
|
||||
placeholder="Type a message…"
|
||||
class="chat-input-textarea"
|
||||
></textarea>
|
||||
<button type="submit" class="btn-primary">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-scroll chat to bottom
|
||||
const hist = document.getElementById("chat-history");
|
||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,192 +1,192 @@
|
||||
{% extends "base.html" %} {% block title %}Video Generation — All You Can GET
|
||||
AI{% endblock %} {% block content %}
|
||||
<div class="card">
|
||||
<h1>Video Generation</h1>
|
||||
|
||||
<div class="tabs-container">
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="text-to-video" type="button">
|
||||
Text to video
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="image-to-video" type="button">
|
||||
Image to video
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Text-to-video -->
|
||||
<div class="tab-panel active" id="tab-text-to-video">
|
||||
<form method="post">
|
||||
<input type="hidden" name="mode" value="text" />
|
||||
|
||||
<label for="model-t">Model</label>
|
||||
{% if models %}
|
||||
<select id="model-t" name="model" required>
|
||||
{% for m in models %}
|
||||
<option value="{{ m.id }}" {% if request.form.get('model', '') == m.id and request.form.get('mode','text')=='text' %}selected{% endif %}>{{ m.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input
|
||||
id="model-t"
|
||||
name="model"
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. openai/sora-2-pro"
|
||||
value="{{ request.form.get('model', '') if request.form.get('mode','text')=='text' else '' }}"
|
||||
/>
|
||||
<p class="text-muted mt-1">No models available</p>
|
||||
{% endif %}
|
||||
|
||||
<label for="prompt-t">Prompt</label>
|
||||
<textarea
|
||||
id="prompt-t"
|
||||
name="prompt"
|
||||
rows="4"
|
||||
required
|
||||
placeholder="Describe the video you want…"
|
||||
>
|
||||
{{ request.form.get('prompt', '') if request.form.get('mode','text')=='text' else '' }}</textarea
|
||||
>
|
||||
|
||||
<label for="aspect-t">Aspect ratio</label>
|
||||
<select id="aspect-t" name="aspect_ratio">
|
||||
<option value="16:9">16:9 (landscape)</option>
|
||||
<option value="9:16">9:16 (portrait)</option>
|
||||
<option value="1:1">1:1 (square)</option>
|
||||
</select>
|
||||
|
||||
<label for="res-t">Resolution</label>
|
||||
<select id="res-t" name="resolution">
|
||||
<option value="">Auto (default)</option>
|
||||
<option value="480p">480p</option>
|
||||
<option value="720p">720p</option>
|
||||
<option value="1080p">1080p</option>
|
||||
</select>
|
||||
|
||||
<label for="duration-t">Duration (seconds)</label>
|
||||
<select id="duration-t" name="duration_seconds">
|
||||
<option value="4">4s</option>
|
||||
<option value="8">8s</option>
|
||||
<option value="12" selected>12s</option>
|
||||
<option value="16">16s</option>
|
||||
<option value="20">20s</option>
|
||||
</select>
|
||||
|
||||
<button type="submit">Generate video</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Image-to-video -->
|
||||
<div class="tab-panel" id="tab-image-to-video">
|
||||
<form method="post">
|
||||
<input type="hidden" name="mode" value="image" />
|
||||
|
||||
<label for="model-i">Model</label>
|
||||
{% if models %}
|
||||
<select id="model-i" name="model" required>
|
||||
{% for m in models %}
|
||||
<option value="{{ m.id }}" {% if request.form.get('model', '') == m.id and request.form.get('mode')=='image' %}selected{% endif %}>{{ m.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input
|
||||
id="model-i"
|
||||
name="model"
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. openai/sora-2-pro"
|
||||
value="{{ request.form.get('model', '') if request.form.get('mode')=='image' else '' }}"
|
||||
/>
|
||||
<p class="text-muted mt-1">No models available</p>
|
||||
{% endif %}
|
||||
|
||||
<label for="image_url">Source image URL</label>
|
||||
<input
|
||||
id="image_url"
|
||||
name="image_url"
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://example.com/photo.jpg"
|
||||
value="{{ request.form.get('image_url', '') }}"
|
||||
/>
|
||||
|
||||
<label for="prompt-i">Motion prompt</label>
|
||||
<textarea
|
||||
id="prompt-i"
|
||||
name="prompt"
|
||||
rows="3"
|
||||
required
|
||||
placeholder="Describe the motion or transformation…"
|
||||
>
|
||||
{{ request.form.get('prompt', '') if request.form.get('mode')=='image' else '' }}</textarea
|
||||
>
|
||||
|
||||
<label for="aspect-i">Aspect ratio</label>
|
||||
<select id="aspect-i" name="aspect_ratio">
|
||||
<option value="16:9">16:9 (landscape)</option>
|
||||
<option value="9:16">9:16 (portrait)</option>
|
||||
<option value="1:1">1:1 (square)</option>
|
||||
</select>
|
||||
|
||||
<label for="res-i">Resolution</label>
|
||||
<select id="res-i" name="resolution">
|
||||
<option value="">Auto (default)</option>
|
||||
<option value="480p">480p</option>
|
||||
<option value="720p">720p</option>
|
||||
<option value="1080p">1080p</option>
|
||||
</select>
|
||||
|
||||
<label for="duration-i">Duration (seconds)</label>
|
||||
<select id="duration-i" name="duration_seconds">
|
||||
<option value="4">4s</option>
|
||||
<option value="8">8s</option>
|
||||
<option value="12" selected>12s</option>
|
||||
<option value="16">16s</option>
|
||||
<option value="20">20s</option>
|
||||
</select>
|
||||
|
||||
<button type="submit">Generate video from image</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error mt-2">{{ error }}</div>
|
||||
{% endif %} {% if result %}
|
||||
<div class="result">
|
||||
<h2>Video job</h2>
|
||||
<p>Job ID: <code>{{ result.db_id or result.id }}</code></p>
|
||||
{% if result.status in ('queued', 'processing') and result.db_id %}
|
||||
<div id="video-poll-status" data-video-id="{{ result.db_id }}">
|
||||
<p>
|
||||
<span id="poll-status-text"
|
||||
>Status: <strong>{{ result.status }}</strong></span
|
||||
>
|
||||
— checking for updates every 5 s…
|
||||
</p>
|
||||
<div id="poll-video-container"></div>
|
||||
<button
|
||||
id="cancel-video-btn"
|
||||
class="mt-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm"
|
||||
>
|
||||
Cancel Job
|
||||
</button>
|
||||
<p id="cancel-msg" class="text-sm mt-2 hidden"></p>
|
||||
</div>
|
||||
{% elif result.video_url %}
|
||||
<video
|
||||
src="{{ result.video_url }}"
|
||||
controls
|
||||
class="generated-video"
|
||||
></video>
|
||||
{% elif result.status == 'failed' %}
|
||||
<div class="alert alert-error">
|
||||
Generation failed: {{ result.error or 'Unknown error' }}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>Status: <strong>{{ result.status }}</strong></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %} {% block title %}Video Generation — All You Can GET
|
||||
AI{% endblock %} {% block content %}
|
||||
<div class="card">
|
||||
<h1>Video Generation</h1>
|
||||
|
||||
<div class="tabs-container">
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="text-to-video" type="button">
|
||||
Text to video
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="image-to-video" type="button">
|
||||
Image to video
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Text-to-video -->
|
||||
<div class="tab-panel active" id="tab-text-to-video">
|
||||
<form method="post">
|
||||
<input type="hidden" name="mode" value="text" />
|
||||
|
||||
<label for="model-t">Model</label>
|
||||
{% if models %}
|
||||
<select id="model-t" name="model" required>
|
||||
{% for m in models %}
|
||||
<option value="{{ m.id }}" {% if request.form.get('model', '') == m.id and request.form.get('mode','text')=='text' %}selected{% endif %}>{{ m.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input
|
||||
id="model-t"
|
||||
name="model"
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. openai/sora-2-pro"
|
||||
value="{{ request.form.get('model', '') if request.form.get('mode','text')=='text' else '' }}"
|
||||
/>
|
||||
<p class="text-muted mt-1">No models available</p>
|
||||
{% endif %}
|
||||
|
||||
<label for="prompt-t">Prompt</label>
|
||||
<textarea
|
||||
id="prompt-t"
|
||||
name="prompt"
|
||||
rows="4"
|
||||
required
|
||||
placeholder="Describe the video you want…"
|
||||
>
|
||||
{{ request.form.get('prompt', '') if request.form.get('mode','text')=='text' else '' }}</textarea
|
||||
>
|
||||
|
||||
<label for="aspect-t">Aspect ratio</label>
|
||||
<select id="aspect-t" name="aspect_ratio">
|
||||
<option value="16:9">16:9 (landscape)</option>
|
||||
<option value="9:16">9:16 (portrait)</option>
|
||||
<option value="1:1">1:1 (square)</option>
|
||||
</select>
|
||||
|
||||
<label for="res-t">Resolution</label>
|
||||
<select id="res-t" name="resolution">
|
||||
<option value="">Auto (default)</option>
|
||||
<option value="480p">480p</option>
|
||||
<option value="720p">720p</option>
|
||||
<option value="1080p">1080p</option>
|
||||
</select>
|
||||
|
||||
<label for="duration-t">Duration (seconds)</label>
|
||||
<select id="duration-t" name="duration_seconds">
|
||||
<option value="4">4s</option>
|
||||
<option value="8">8s</option>
|
||||
<option value="12" selected>12s</option>
|
||||
<option value="16">16s</option>
|
||||
<option value="20">20s</option>
|
||||
</select>
|
||||
|
||||
<button type="submit">Generate video</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Image-to-video -->
|
||||
<div class="tab-panel" id="tab-image-to-video">
|
||||
<form method="post">
|
||||
<input type="hidden" name="mode" value="image" />
|
||||
|
||||
<label for="model-i">Model</label>
|
||||
{% if models %}
|
||||
<select id="model-i" name="model" required>
|
||||
{% for m in models %}
|
||||
<option value="{{ m.id }}" {% if request.form.get('model', '') == m.id and request.form.get('mode')=='image' %}selected{% endif %}>{{ m.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input
|
||||
id="model-i"
|
||||
name="model"
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. openai/sora-2-pro"
|
||||
value="{{ request.form.get('model', '') if request.form.get('mode')=='image' else '' }}"
|
||||
/>
|
||||
<p class="text-muted mt-1">No models available</p>
|
||||
{% endif %}
|
||||
|
||||
<label for="image_url">Source image URL</label>
|
||||
<input
|
||||
id="image_url"
|
||||
name="image_url"
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://example.com/photo.jpg"
|
||||
value="{{ request.form.get('image_url', '') }}"
|
||||
/>
|
||||
|
||||
<label for="prompt-i">Motion prompt</label>
|
||||
<textarea
|
||||
id="prompt-i"
|
||||
name="prompt"
|
||||
rows="3"
|
||||
required
|
||||
placeholder="Describe the motion or transformation…"
|
||||
>
|
||||
{{ request.form.get('prompt', '') if request.form.get('mode')=='image' else '' }}</textarea
|
||||
>
|
||||
|
||||
<label for="aspect-i">Aspect ratio</label>
|
||||
<select id="aspect-i" name="aspect_ratio">
|
||||
<option value="16:9">16:9 (landscape)</option>
|
||||
<option value="9:16">9:16 (portrait)</option>
|
||||
<option value="1:1">1:1 (square)</option>
|
||||
</select>
|
||||
|
||||
<label for="res-i">Resolution</label>
|
||||
<select id="res-i" name="resolution">
|
||||
<option value="">Auto (default)</option>
|
||||
<option value="480p">480p</option>
|
||||
<option value="720p">720p</option>
|
||||
<option value="1080p">1080p</option>
|
||||
</select>
|
||||
|
||||
<label for="duration-i">Duration (seconds)</label>
|
||||
<select id="duration-i" name="duration_seconds">
|
||||
<option value="4">4s</option>
|
||||
<option value="8">8s</option>
|
||||
<option value="12" selected>12s</option>
|
||||
<option value="16">16s</option>
|
||||
<option value="20">20s</option>
|
||||
</select>
|
||||
|
||||
<button type="submit">Generate video from image</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error mt-2">{{ error }}</div>
|
||||
{% endif %} {% if result %}
|
||||
<div class="result">
|
||||
<h2>Video job</h2>
|
||||
<p>Job ID: <code>{{ result.db_id or result.id }}</code></p>
|
||||
{% if result.status in ('queued', 'processing') and result.db_id %}
|
||||
<div id="video-poll-status" data-video-id="{{ result.db_id }}">
|
||||
<p>
|
||||
<span id="poll-status-text"
|
||||
>Status: <strong>{{ result.status }}</strong></span
|
||||
>
|
||||
— checking for updates every 5 s…
|
||||
</p>
|
||||
<div id="poll-video-container"></div>
|
||||
<button
|
||||
id="cancel-video-btn"
|
||||
class="mt-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm"
|
||||
>
|
||||
Cancel Job
|
||||
</button>
|
||||
<p id="cancel-msg" class="text-sm mt-2 hidden"></p>
|
||||
</div>
|
||||
{% elif result.video_url %}
|
||||
<video
|
||||
src="{{ result.video_url }}"
|
||||
controls
|
||||
class="generated-video"
|
||||
></video>
|
||||
{% elif result.status == 'failed' %}
|
||||
<div class="alert alert-error">
|
||||
Generation failed: {{ result.error or 'Unknown error' }}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>Status: <strong>{{ result.status }}</strong></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,35 +1,35 @@
|
||||
{% extends "base.html" %} {% block title %}Generated Image{% endblock %} {%
|
||||
block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<a
|
||||
href="{{ url_for('gallery.index') }}"
|
||||
class="text-blue-400 hover:underline mb-4 inline-block"
|
||||
>← Back to Gallery</a
|
||||
>
|
||||
|
||||
{% if image %}
|
||||
<h1 class="text-2xl font-bold mb-4">Generated Image</h1>
|
||||
<div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
||||
<img
|
||||
src="{{ image.image_data }}"
|
||||
alt="{{ image.prompt }}"
|
||||
class="w-full object-contain"
|
||||
/>
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Prompt</h2>
|
||||
<p class="text-gray-300 bg-gray-900 p-3 rounded-md">{{ image.prompt }}</p>
|
||||
<div class="mt-4 text-sm text-gray-400">
|
||||
<p><strong>Model:</strong> {{ image.model_id }}</p>
|
||||
<p>
|
||||
<strong>Created:</strong> {{ image.created_at | fromisoformat |
|
||||
humantime }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h1 class="text-2xl font-bold">Image not found</h1>
|
||||
<p class="text-gray-400 mt-2">Could not find details for this image.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %} {% block title %}Generated Image{% endblock %} {%
|
||||
block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<a
|
||||
href="{{ url_for('gallery.index') }}"
|
||||
class="text-blue-400 hover:underline mb-4 inline-block"
|
||||
>← Back to Gallery</a
|
||||
>
|
||||
|
||||
{% if image %}
|
||||
<h1 class="text-2xl font-bold mb-4">Generated Image</h1>
|
||||
<div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
||||
<img
|
||||
src="{{ image.image_data }}"
|
||||
alt="{{ image.prompt }}"
|
||||
class="w-full object-contain"
|
||||
/>
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Prompt</h2>
|
||||
<p class="text-gray-300 bg-gray-900 p-3 rounded-md">{{ image.prompt }}</p>
|
||||
<div class="mt-4 text-sm text-gray-400">
|
||||
<p><strong>Model:</strong> {{ image.model_id }}</p>
|
||||
<p>
|
||||
<strong>Created:</strong> {{ image.created_at | fromisoformat |
|
||||
humantime }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h1 class="text-2xl font-bold">Image not found</h1>
|
||||
<p class="text-gray-400 mt-2">Could not find details for this image.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,16 +1,16 @@
|
||||
{% extends "base.html" %} {% block title %}Log in — All You Can GET AI{%
|
||||
endblock %} {% block content %}
|
||||
<div class="card">
|
||||
<h1>Log in</h1>
|
||||
<form method="post">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" type="email" required autofocus />
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" required />
|
||||
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
<p>No account? <a href="{{ url_for('auth.register') }}">Register</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %} {% block title %}Log in — All You Can GET AI{%
|
||||
endblock %} {% block content %}
|
||||
<div class="card">
|
||||
<h1>Log in</h1>
|
||||
<form method="post">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" type="email" required autofocus />
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" required />
|
||||
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
<p>No account? <a href="{{ url_for('auth.register') }}">Register</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,43 +1,43 @@
|
||||
{% extends "base.html" %} {% block title %}Profile — All You Can GET AI{%
|
||||
endblock %} {% block content %}
|
||||
<div class="card">
|
||||
<h1>Your Profile</h1>
|
||||
|
||||
<h2 class="section-title" style="margin-top: 0">Account details</h2>
|
||||
<p class="text-muted" style="font-size: 0.875rem; margin-bottom: 1.5rem">
|
||||
Current email:
|
||||
<strong style="color: var(--text)">{{ user.get('email', '') }}</strong>
|
||||
· Role:
|
||||
<span class="role-badge role-{{ user.get('role','user') }}"
|
||||
>{{ user.get('role', 'user') }}</span
|
||||
>
|
||||
</p>
|
||||
|
||||
<h2 class="section-title">Update email</h2>
|
||||
<form method="post">
|
||||
<label for="email">New email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="{{ user.get('email', '') }}"
|
||||
/>
|
||||
<input type="hidden" name="password" value="" />
|
||||
<button type="submit">Save email</button>
|
||||
</form>
|
||||
|
||||
<h2 class="section-title" style="margin-top: 2rem">Change password</h2>
|
||||
<form method="post">
|
||||
<label for="password">New password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
minlength="8"
|
||||
/>
|
||||
<input type="hidden" name="email" value="" />
|
||||
<button type="submit">Save password</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %} {% block title %}Profile — All You Can GET AI{%
|
||||
endblock %} {% block content %}
|
||||
<div class="card">
|
||||
<h1>Your Profile</h1>
|
||||
|
||||
<h2 class="section-title" style="margin-top: 0">Account details</h2>
|
||||
<p class="text-muted" style="font-size: 0.875rem; margin-bottom: 1.5rem">
|
||||
Current email:
|
||||
<strong style="color: var(--text)">{{ user.get('email', '') }}</strong>
|
||||
· Role:
|
||||
<span class="role-badge role-{{ user.get('role','user') }}"
|
||||
>{{ user.get('role', 'user') }}</span
|
||||
>
|
||||
</p>
|
||||
|
||||
<h2 class="section-title">Update email</h2>
|
||||
<form method="post">
|
||||
<label for="email">New email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="{{ user.get('email', '') }}"
|
||||
/>
|
||||
<input type="hidden" name="password" value="" />
|
||||
<button type="submit">Save email</button>
|
||||
</form>
|
||||
|
||||
<h2 class="section-title" style="margin-top: 2rem">Change password</h2>
|
||||
<form method="post">
|
||||
<label for="password">New password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
minlength="8"
|
||||
/>
|
||||
<input type="hidden" name="email" value="" />
|
||||
<button type="submit">Save password</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,24 +1,24 @@
|
||||
{% extends "base.html" %} {% block title %}Register — All You Can GET AI{%
|
||||
endblock %} {% block content %}
|
||||
<div class="card">
|
||||
<h1>Create account</h1>
|
||||
<form method="post">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" type="email" required autofocus />
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
<p>
|
||||
Already have an account? <a href="{{ url_for('auth.login') }}">Log in</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %} {% block title %}Register — All You Can GET AI{%
|
||||
endblock %} {% block content %}
|
||||
<div class="card">
|
||||
<h1>Create account</h1>
|
||||
<form method="post">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" type="email" required autofocus />
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
<p>
|
||||
Already have an account? <a href="{{ url_for('auth.login') }}">Log in</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,40 +1,40 @@
|
||||
{% extends "base.html" %} {% block title %}Uploaded Image{% endblock %} {% block
|
||||
content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<a
|
||||
href="{{ url_for('gallery.index') }}"
|
||||
class="text-blue-400 hover:underline mb-4 inline-block"
|
||||
>← Back to Gallery</a
|
||||
>
|
||||
|
||||
{% if image %}
|
||||
<h1 class="text-2xl font-bold mb-4">Uploaded Image</h1>
|
||||
<div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
||||
<img
|
||||
src="{{ url_for('gallery.serve_uploaded_image', image_id=image.id) }}"
|
||||
alt="{{ image.filename }}"
|
||||
class="w-full object-contain"
|
||||
/>
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Details</h2>
|
||||
<div class="mt-4 text-sm text-gray-400">
|
||||
<p><strong>Filename:</strong> {{ image.filename }}</p>
|
||||
<p><strong>Content Type:</strong> {{ image.content_type }}</p>
|
||||
<p>
|
||||
<strong>Size:</strong> {{ (image.size_bytes / 1024) | round(2) }} KB
|
||||
</p>
|
||||
<p>
|
||||
<strong>Uploaded:</strong> {{ image.created_at | fromisoformat |
|
||||
humantime }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h1 class="text-2xl font-bold">Image not found</h1>
|
||||
<p class="text-gray-400 mt-2">
|
||||
Could not find details for this uploaded image.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %} {% block title %}Uploaded Image{% endblock %} {% block
|
||||
content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<a
|
||||
href="{{ url_for('gallery.index') }}"
|
||||
class="text-blue-400 hover:underline mb-4 inline-block"
|
||||
>← Back to Gallery</a
|
||||
>
|
||||
|
||||
{% if image %}
|
||||
<h1 class="text-2xl font-bold mb-4">Uploaded Image</h1>
|
||||
<div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
||||
<img
|
||||
src="{{ url_for('gallery.serve_uploaded_image', image_id=image.id) }}"
|
||||
alt="{{ image.filename }}"
|
||||
class="w-full object-contain"
|
||||
/>
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Details</h2>
|
||||
<div class="mt-4 text-sm text-gray-400">
|
||||
<p><strong>Filename:</strong> {{ image.filename }}</p>
|
||||
<p><strong>Content Type:</strong> {{ image.content_type }}</p>
|
||||
<p>
|
||||
<strong>Size:</strong> {{ (image.size_bytes / 1024) | round(2) }} KB
|
||||
</p>
|
||||
<p>
|
||||
<strong>Uploaded:</strong> {{ image.created_at | fromisoformat |
|
||||
humantime }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h1 class="text-2xl font-bold">Image not found</h1>
|
||||
<p class="text-gray-400 mt-2">
|
||||
Could not find details for this uploaded image.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,77 +1,77 @@
|
||||
{% extends "base.html" %} {% block title %}Generated Video{% endblock %} {%
|
||||
block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<a
|
||||
href="{{ url_for('gallery.index') }}"
|
||||
class="text-blue-400 hover:underline mb-4 inline-block"
|
||||
>← Back to Gallery</a
|
||||
>
|
||||
|
||||
{% if video %}
|
||||
<h1 class="text-2xl font-bold mb-4">Video Generation Job</h1>
|
||||
<div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
||||
{% if video.status == 'completed' and video.video_url %}
|
||||
<video src="{{ video.video_url }}" controls class="w-full"></video>
|
||||
{% elif video.status in ('queued', 'processing') %}
|
||||
<div
|
||||
class="w-full bg-black aspect-video flex flex-col items-center justify-center p-6 text-center"
|
||||
id="video-poll-status"
|
||||
data-video-id="{{ video.id }}"
|
||||
>
|
||||
<p class="text-xl font-semibold">
|
||||
Status: <strong id="poll-status-text">{{ video.status }}</strong>
|
||||
</p>
|
||||
<p class="text-gray-400 mt-2">
|
||||
Your video is being processed. This page will update automatically when
|
||||
it's ready.
|
||||
</p>
|
||||
<div class="spinner mt-4"></div>
|
||||
<button
|
||||
id="cancel-video-btn"
|
||||
class="mt-4 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm"
|
||||
>
|
||||
Cancel Job
|
||||
</button>
|
||||
<p id="cancel-msg" class="text-sm mt-2 hidden"></p>
|
||||
</div>
|
||||
{% elif video.status == 'failed' %}
|
||||
<div
|
||||
class="w-full bg-black aspect-video flex flex-col items-center justify-center p-6 text-center"
|
||||
>
|
||||
<p class="text-xl font-semibold text-red-500">Generation Failed</p>
|
||||
<p class="text-gray-400 mt-2">
|
||||
{{ video.error or 'An unknown error occurred.' }}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div
|
||||
class="w-full bg-black aspect-video flex flex-col items-center justify-center p-6 text-center"
|
||||
>
|
||||
<p class="text-xl font-semibold">Video Not Available</p>
|
||||
<p class="text-gray-400 mt-2">Status: {{ video.status }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Prompt</h2>
|
||||
<p class="text-gray-300 bg-gray-900 p-3 rounded-md">{{ video.prompt }}</p>
|
||||
<div class="mt-4 text-sm text-gray-400">
|
||||
<p><strong>Model:</strong> {{ video.model_id }}</p>
|
||||
<p><strong>Job ID:</strong> <code>{{ video.job_id }}</code></p>
|
||||
<p>
|
||||
<strong>Created:</strong> {{ video.created_at | fromisoformat |
|
||||
humantime }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Last Update:</strong> {{ video.updated_at | fromisoformat |
|
||||
humantime }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h1 class="text-2xl font-bold">Video job not found</h1>
|
||||
<p class="text-gray-400 mt-2">Could not find details for this video job.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %} {% block title %}Generated Video{% endblock %} {%
|
||||
block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<a
|
||||
href="{{ url_for('gallery.index') }}"
|
||||
class="text-blue-400 hover:underline mb-4 inline-block"
|
||||
>← Back to Gallery</a
|
||||
>
|
||||
|
||||
{% if video %}
|
||||
<h1 class="text-2xl font-bold mb-4">Video Generation Job</h1>
|
||||
<div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
||||
{% if video.status == 'completed' and video.video_url %}
|
||||
<video src="{{ video.video_url }}" controls class="w-full"></video>
|
||||
{% elif video.status in ('queued', 'processing') %}
|
||||
<div
|
||||
class="w-full bg-black aspect-video flex flex-col items-center justify-center p-6 text-center"
|
||||
id="video-poll-status"
|
||||
data-video-id="{{ video.id }}"
|
||||
>
|
||||
<p class="text-xl font-semibold">
|
||||
Status: <strong id="poll-status-text">{{ video.status }}</strong>
|
||||
</p>
|
||||
<p class="text-gray-400 mt-2">
|
||||
Your video is being processed. This page will update automatically when
|
||||
it's ready.
|
||||
</p>
|
||||
<div class="spinner mt-4"></div>
|
||||
<button
|
||||
id="cancel-video-btn"
|
||||
class="mt-4 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm"
|
||||
>
|
||||
Cancel Job
|
||||
</button>
|
||||
<p id="cancel-msg" class="text-sm mt-2 hidden"></p>
|
||||
</div>
|
||||
{% elif video.status == 'failed' %}
|
||||
<div
|
||||
class="w-full bg-black aspect-video flex flex-col items-center justify-center p-6 text-center"
|
||||
>
|
||||
<p class="text-xl font-semibold text-red-500">Generation Failed</p>
|
||||
<p class="text-gray-400 mt-2">
|
||||
{{ video.error or 'An unknown error occurred.' }}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div
|
||||
class="w-full bg-black aspect-video flex flex-col items-center justify-center p-6 text-center"
|
||||
>
|
||||
<p class="text-xl font-semibold">Video Not Available</p>
|
||||
<p class="text-gray-400 mt-2">Status: {{ video.status }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Prompt</h2>
|
||||
<p class="text-gray-300 bg-gray-900 p-3 rounded-md">{{ video.prompt }}</p>
|
||||
<div class="mt-4 text-sm text-gray-400">
|
||||
<p><strong>Model:</strong> {{ video.model_id }}</p>
|
||||
<p><strong>Job ID:</strong> <code>{{ video.job_id }}</code></p>
|
||||
<p>
|
||||
<strong>Created:</strong> {{ video.created_at | fromisoformat |
|
||||
humantime }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Last Update:</strong> {{ video.updated_at | fromisoformat |
|
||||
humantime }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h1 class="text-2xl font-bold">Video job not found</h1>
|
||||
<p class="text-gray-400 mt-2">Could not find details for this video job.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,2 +1,2 @@
|
||||
pytest
|
||||
pytest
|
||||
pytest-mock
|
||||
@@ -1,7 +1,7 @@
|
||||
Flask
|
||||
gunicorn
|
||||
httpx
|
||||
itsdangerous
|
||||
Jinja2
|
||||
MarkupSafe
|
||||
Werkzeug
|
||||
Flask
|
||||
gunicorn
|
||||
httpx
|
||||
itsdangerous
|
||||
Jinja2
|
||||
MarkupSafe
|
||||
Werkzeug
|
||||
+644
-644
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user