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:
zwitschi committed 2026-09-03 15:05:27 +02:00
1 parent 3b8c30c289
commit a4b874598d
88 files changed
+9766 -9766

No files matched your search

+16 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7 -7
View File
@@ -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)
+18 -18
View File
@@ -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)
+74 -74
View File
@@ -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
View File
@@ -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"))
+28 -28
View File
@@ -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)
+74 -74
View File
@@ -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
View File
@@ -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
+32 -32
View File
@@ -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
View File
@@ -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);
}
});
File diff suppressed because it is too large. Load diff
+279 -279
View File
@@ -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, "&quot;")}">${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, "&quot;")}">${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 %}
+154 -154
View File
@@ -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 %}
+182 -182
View File
@@ -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 %}
+60 -60
View File
@@ -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>
+116 -116
View File
@@ -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 }} &hellip;</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 }} &hellip;</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 }} &mdash; {{ (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 }} &hellip;</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 }} &hellip;</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 }} &mdash; {{ (img.size_bytes / 1024) | round(1) }} KB
</p>
</a>
{% endfor %}
</div>
</div>
{% endif %} {% endblock %}
+300 -300
View File
@@ -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>
+9 -9
View File
@@ -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 %}
+82 -82
View File
@@ -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 %}
+93 -93
View File
@@ -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 &amp; 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 &amp; 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 %}
+192 -192
View File
@@ -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
>
&mdash; checking for updates every 5 s&hellip;
</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
>
&mdash; checking for updates every 5 s&hellip;
</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 %}
+35 -35
View File
@@ -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"
>&larr; 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"
>&larr; 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 %}
+16 -16
View File
@@ -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 %}
+43 -43
View File
@@ -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>
&nbsp;·&nbsp; 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>
&nbsp;·&nbsp; 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 %}
+24 -24
View File
@@ -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 %}
+40 -40
View File
@@ -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"
>&larr; 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"
>&larr; 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 %}
+77 -77
View File
@@ -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"
>&larr; 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"
>&larr; 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 %}