From f43b13f6253d3e3d9c0d96ed2171e4cd3b27dbc6 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Sat, 30 May 2026 19:39:30 +0200 Subject: [PATCH] Add blueprints for authentication, admin, dashboard, gallery, generation, and profile routes - Created `__init__.py` for blueprint registration. - Implemented `auth.py` for user authentication (login, register, logout). - Added `admin.py` for admin functionalities (user management, stats). - Developed `dashboard.py` for user dashboard displaying user info and generated content. - Created `gallery.py` for managing and displaying images and videos. - Implemented `generate.py` for text, image, and video generation functionalities. - Added `profile.py` for user profile management. - Updated templates to reflect new route structures and improve navigation. --- frontend/app/__init__.py | 17 + frontend/app/filters.py | 41 ++ frontend/app/helpers.py | 92 ++++ frontend/app/main.py | 590 +--------------------- frontend/app/routes/__init__.py | 19 + frontend/app/routes/admin.py | 75 +++ frontend/app/routes/auth.py | 58 +++ frontend/app/routes/dashboard.py | 29 ++ frontend/app/routes/gallery.py | 75 +++ frontend/app/routes/generate.py | 187 +++++++ frontend/app/routes/profile.py | 33 ++ frontend/app/templates/admin.html | 4 +- frontend/app/templates/base.html | 24 +- frontend/app/templates/dashboard.html | 12 +- frontend/app/templates/gallery.html | 14 +- frontend/app/templates/image_detail.html | 2 +- frontend/app/templates/login.html | 2 +- frontend/app/templates/register.html | 4 +- frontend/app/templates/upload_detail.html | 4 +- frontend/app/templates/video_detail.html | 2 +- 20 files changed, 667 insertions(+), 617 deletions(-) create mode 100644 frontend/app/filters.py create mode 100644 frontend/app/helpers.py create mode 100644 frontend/app/routes/__init__.py create mode 100644 frontend/app/routes/admin.py create mode 100644 frontend/app/routes/auth.py create mode 100644 frontend/app/routes/dashboard.py create mode 100644 frontend/app/routes/gallery.py create mode 100644 frontend/app/routes/generate.py create mode 100644 frontend/app/routes/profile.py diff --git a/frontend/app/__init__.py b/frontend/app/__init__.py index e69de29..9963e3f 100644 --- a/frontend/app/__init__.py +++ b/frontend/app/__init__.py @@ -0,0 +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) + + return app \ No newline at end of file diff --git a/frontend/app/filters.py b/frontend/app/filters.py new file mode 100644 index 0000000..ebbfe0f --- /dev/null +++ b/frontend/app/filters.py @@ -0,0 +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) diff --git a/frontend/app/helpers.py b/frontend/app/helpers.py new file mode 100644 index 0000000..4ec0585 --- /dev/null +++ b/frontend/app/helpers.py @@ -0,0 +1,92 @@ +"""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", {}) + if token: + headers["Authorization"] = f"Bearer {token}" + return httpx.request(method, _backend(path), headers=headers, timeout=30, **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")) + 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 session.get("user_role") != "admin": + flash("Admin access required.", "error") + return redirect(url_for("dashboard.index")) + return view(*args, **kwargs) + return wrapped diff --git a/frontend/app/main.py b/frontend/app/main.py index d6d94d0..cd5a724 100644 --- a/frontend/app/main.py +++ b/frontend/app/main.py @@ -1,587 +1,7 @@ -"""Flask frontend application.""" -import functools -from datetime import datetime, timezone +"""Flask frontend application entry point.""" +from . import create_app -import httpx -from flask import ( - Flask, - Response, - flash, - jsonify, - redirect, - render_template, - request, - session, - url_for, -) +app = create_app() -from .config import Config - -app = Flask(__name__) -app.config.from_object(Config) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -@app.template_filter("fromisoformat") -def from_iso_format(s: str) -> datetime: - """Convert ISO 8601 string to datetime object.""" - return datetime.fromisoformat(s) - - -@app.template_filter("humantime") -def human_time(dt: datetime) -> str: - """Format a datetime object into a human-readable relative time.""" - now = datetime.now(timezone.utc) - # Ensure dt is aware for comparison - 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 _backend(path: str) -> str: - return f"{app.config['BACKEND_URL']}{path}" - - -def _api(method: str, path: str, *, token: str | None = None, **kwargs): - headers = kwargs.pop("headers", {}) - if token: - headers["Authorization"] = f"Bearer {token}" - return httpx.request(method, _backend(path), headers=headers, timeout=30, **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("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("login")) - if session.get("user_role") != "admin": - flash("Admin access required.", "error") - return redirect(url_for("dashboard")) - return view(*args, **kwargs) - return wrapped - - -# --------------------------------------------------------------------------- -# Auth routes -# --------------------------------------------------------------------------- - -@app.get("/") -def index(): - if "access_token" in session: - return redirect(url_for("dashboard")) - return redirect(url_for("login")) - - -@app.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")) - flash("Invalid email or password.", "error") - return render_template("login.html") - - -@app.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("login")) - detail = resp.json().get("detail", "Registration failed.") - flash(detail, "error") - return render_template("register.html") - - -@app.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("login")) - - -# --------------------------------------------------------------------------- -# Authenticated routes -# --------------------------------------------------------------------------- - -@app.get("/dashboard") -@login_required -def dashboard(): - 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) - - -@app.get("/gallery") -@login_required -def gallery(): - token = session["access_token"] - - # Fetch all content types - 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 [] - - # Separate pending videos - 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, - ) - - -@app.get("/gallery/image/") -@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) - - -@app.get("/gallery/video/") -@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) - - -@app.get("/gallery/upload/") -@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) - - -# ── Generate ────────────────────────────────────────────────────────────── - -@app.get("/images//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"), - ) - - -@app.get("/generate") -@login_required -def generate(): - return redirect(url_for("generate_text")) - - -@app.route("/generate/text", methods=["GET", "POST"]) -@login_required -def generate_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() - - # Persist model + system_prompt across turns - session["chat_model"] = model - session["chat_system_prompt"] = system_prompt - - if prompt: - # Build messages: history (user/assistant only) + new user msg - 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, - ) - - -@app.route("/generate/image", methods=["GET", "POST"]) -@login_required -def generate_image(): - result = error = None - token = session["access_token"] - if request.method == "POST": - # Upload reference image if provided - 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 = up_resp.json().get("detail", "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, 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 = resp.json().get("detail", "Generation failed.") - models = _load_models(token, "image") - return render_template("generate_image.html", result=result, error=error, models=models) - - -@app.route("/generate/video", methods=["GET", "POST"]) -@login_required -def generate_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() - # On success, redirect to the detail page to monitor progress - db_id = result.get("db_id") - if db_id: - return redirect(url_for("video_detail", video_id=db_id)) - # Fallback for older backend versions - flash("Video job started.", "success") - return redirect(url_for("gallery")) - else: - error = resp.json().get("detail", "Generation failed.") - - models = _load_models(token, "video") - return render_template("generate_video.html", error=error, models=models) - - -@app.get("/generate/video/status") -@login_required -def generate_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 - - -@app.get("/generate/video//status") -@login_required -def generate_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 - - -@app.post("/generate/video//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 - - -# ── Admin ───────────────────────────────────────────────────────────────── - -@app.get("/admin") -@admin_required -def admin(): - 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) - - -@app.post("/admin/users//role") -@admin_required -def admin_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")) - - -@app.post("/admin/users//delete") -@admin_required -def admin_delete_user(user_id: str): - _api("DELETE", f"/users/{user_id}", token=session["access_token"]) - flash("User deleted.", "success") - return redirect(url_for("admin")) - - -@app.get("/admin/models") -@admin_required -def admin_models(): - """Show model cache status and list all models.""" - return render_template("admin/models.html") - - -# ── Admin API proxies (same-origin for browser JS, avoids mixed-content) ── - -@app.get("/api/admin/videos") -@admin_required -def api_admin_list_videos(): - resp = _api("GET", "/admin/videos", token=session["access_token"]) - return jsonify(resp.json()), resp.status_code - - -@app.post("/api/admin/videos//retry") -@admin_required -def api_admin_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 - - -@app.post("/api/admin/videos//cancel") -@admin_required -def api_admin_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 - - -@app.delete("/api/admin/videos/") -@admin_required -def api_admin_delete_video(job_id: str): - resp = _api( - "DELETE", f"/admin/videos/{job_id}", token=session["access_token"]) - return jsonify(resp.json()), resp.status_code - - -# ── Profile ─────────────────────────────────────────────────────────────── - -@app.route("/users/profile", methods=["GET", "POST"]) -@login_required -def profile(): - 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")) - resp = _api("GET", "/users/me", token=token) - user = resp.json() if resp.status_code == 200 else {} - return render_template("profile.html", user=user) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=12016, debug=True) diff --git a/frontend/app/routes/__init__.py b/frontend/app/routes/__init__.py new file mode 100644 index 0000000..def0cd1 --- /dev/null +++ b/frontend/app/routes/__init__.py @@ -0,0 +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) + app.register_blueprint(profile_bp) \ No newline at end of file diff --git a/frontend/app/routes/admin.py b/frontend/app/routes/admin.py new file mode 100644 index 0000000..86049d8 --- /dev/null +++ b/frontend/app/routes/admin.py @@ -0,0 +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//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//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//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//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/") +@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 \ No newline at end of file diff --git a/frontend/app/routes/auth.py b/frontend/app/routes/auth.py new file mode 100644 index 0000000..35b1e2d --- /dev/null +++ b/frontend/app/routes/auth.py @@ -0,0 +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() + return redirect(url_for("auth.login")) \ No newline at end of file diff --git a/frontend/app/routes/dashboard.py b/frontend/app/routes/dashboard.py new file mode 100644 index 0000000..4cd6698 --- /dev/null +++ b/frontend/app/routes/dashboard.py @@ -0,0 +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, + completed_videos=completed_videos) \ No newline at end of file diff --git a/frontend/app/routes/gallery.py b/frontend/app/routes/gallery.py new file mode 100644 index 0000000..aded970 --- /dev/null +++ b/frontend/app/routes/gallery.py @@ -0,0 +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/") +@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/") +@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/") +@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//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"), + ) \ No newline at end of file diff --git a/frontend/app/routes/generate.py b/frontend/app/routes/generate.py new file mode 100644 index 0000000..17e2994 --- /dev/null +++ b/frontend/app/routes/generate.py @@ -0,0 +1,187 @@ +"""Generate blueprint — text, image, video generation.""" +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__) + + +@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": + 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 = up_resp.json().get("detail", "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, 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 = resp.json().get("detail", "Generation failed.") + 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//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//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 \ No newline at end of file diff --git a/frontend/app/routes/profile.py b/frontend/app/routes/profile.py new file mode 100644 index 0000000..08e6554 --- /dev/null +++ b/frontend/app/routes/profile.py @@ -0,0 +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 {} + return render_template("profile.html", user=user) \ No newline at end of file diff --git a/frontend/app/templates/admin.html b/frontend/app/templates/admin.html index 6a66634..db9fc61 100644 --- a/frontend/app/templates/admin.html +++ b/frontend/app/templates/admin.html @@ -42,7 +42,7 @@
-

No account? Register

+

No account? Register

{% endblock %} diff --git a/frontend/app/templates/register.html b/frontend/app/templates/register.html index cbe9ce3..7884de0 100644 --- a/frontend/app/templates/register.html +++ b/frontend/app/templates/register.html @@ -17,6 +17,8 @@ endblock %} {% block content %} -

Already have an account? Log in

+

+ Already have an account? Log in +

{% endblock %} diff --git a/frontend/app/templates/upload_detail.html b/frontend/app/templates/upload_detail.html index e081048..0180cfe 100644 --- a/frontend/app/templates/upload_detail.html +++ b/frontend/app/templates/upload_detail.html @@ -2,7 +2,7 @@ content %}
← Back to Gallery @@ -11,7 +11,7 @@ content %}

Uploaded Image

{{ image.filename }} diff --git a/frontend/app/templates/video_detail.html b/frontend/app/templates/video_detail.html index 63f6c58..a829cc3 100644 --- a/frontend/app/templates/video_detail.html +++ b/frontend/app/templates/video_detail.html @@ -2,7 +2,7 @@ block content %}