diff --git a/frontend/app/main.py b/frontend/app/main.py index 985644b..a613bf1 100644 --- a/frontend/app/main.py +++ b/frontend/app/main.py @@ -42,6 +42,18 @@ def login_required(view): 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 # --------------------------------------------------------------------------- @@ -63,6 +75,11 @@ def login(): 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") @@ -104,33 +121,129 @@ def dashboard(): return render_template("dashboard.html", user=user) -@app.route("/generate", methods=["GET", "POST"]) +# ── Generate ────────────────────────────────────────────────────────────── + +@app.get("/generate") @login_required def generate(): - result = None - error = None + return redirect(url_for("generate_text")) + + +@app.route("/generate/text", methods=["GET", "POST"]) +@login_required +def generate_text(): + result = error = None if request.method == "POST": - gen_type = request.form.get("type", "text") - model = request.form.get("model", "").strip() - prompt = request.form.get("prompt", "").strip() - token = session["access_token"] - - if gen_type == "text": - resp = _api("POST", "/generate/text", token=token, - json={"model": model, "prompt": prompt}) - elif gen_type == "image": - resp = _api("POST", "/generate/image", token=token, - json={"model": model, "prompt": prompt}) - elif gen_type == "video": - resp = _api("POST", "/generate/video", token=token, - json={"model": model, "prompt": prompt}) - else: - resp = None - - if resp is not None and resp.status_code == 200: + resp = _api("POST", "/generate/text", token=session["access_token"], json={ + "model": request.form.get("model", "").strip(), + "prompt": request.form.get("prompt", "").strip(), + }) + if resp.status_code == 200: result = resp.json() else: - detail = resp.json().get("detail", "Generation failed.") if resp is not None else "Unknown error." - error = detail + error = resp.json().get("detail", "Generation failed.") + return render_template("generate_text.html", result=result, error=error) + + +@app.route("/generate/image", methods=["GET", "POST"]) +@login_required +def generate_image(): + result = error = None + if request.method == "POST": + resp = _api("POST", "/generate/image", token=session["access_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"), + }) + if resp.status_code == 200: + result = resp.json() + else: + error = resp.json().get("detail", "Generation failed.") + return render_template("generate_image.html", result=result, error=error) + + +@app.route("/generate/video", methods=["GET", "POST"]) +@login_required +def generate_video(): + result = error = None + if request.method == "POST": + mode = request.form.get("mode", "text") + token = session["access_token"] + 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"), + }) + 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"), + }) + if resp.status_code == 200: + result = resp.json() + else: + error = resp.json().get("detail", "Generation failed.") + return render_template("generate_video.html", result=result, error=error) + + +# ── 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")) + + +# ── 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) - return render_template("generate.html", result=result, error=error) diff --git a/frontend/app/static/app.js b/frontend/app/static/app.js new file mode 100644 index 0000000..4159418 --- /dev/null +++ b/frontend/app/static/app.js @@ -0,0 +1,36 @@ +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'); + }); + } + + // ── 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'); + }); + }); +}); diff --git a/frontend/app/static/style.css b/frontend/app/static/style.css index b4bd855..0773c06 100644 --- a/frontend/app/static/style.css +++ b/frontend/app/static/style.css @@ -1,3 +1,21 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); + +:root { + --bg: #0f1117; + --surface: #1a1d27; + --surface-2: #22263a; + --border: #2e3250; + --text: #e8eaf6; + --text-muted: #8b90b8; + --accent: #7c6ff7; + --accent-hover: #9d97ff; + --danger: #e05a6a; + --danger-hover: #f07080; + --success: #56c489; + --warning: #f0b429; + --radius: 8px; +} + *, *::before, *::after { @@ -8,19 +26,525 @@ body { font-family: + "Inter", system-ui, -apple-system, sans-serif; - background: #f5f5f5; - color: #222; + background: var(--bg); + color: var(--text); min-height: 100vh; + line-height: 1.6; } -/* Nav */ +/* ─── Nav ──────────────────────────────────────────────── */ header { - background: #1a1a2e; + background: var(--surface); + border-bottom: 1px solid var(--border); padding: 0 1.5rem; + position: sticky; + top: 0; + z-index: 100; } + +nav { + display: flex; + align-items: center; + justify-content: space-between; + height: 3.5rem; + max-width: 1100px; + margin: 0 auto; +} + +.brand { + color: var(--accent-hover); + font-weight: 700; + text-decoration: none; + font-size: 1.1rem; + letter-spacing: -0.02em; +} + +.nav-links { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.nav-links a { + color: var(--text-muted); + text-decoration: none; + padding: 0.4rem 0.75rem; + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 500; + transition: + color 0.15s, + background 0.15s; +} + +.nav-links a:hover { + color: var(--text); + background: var(--surface-2); +} + +/* Dropdown */ +.nav-dropdown { + position: relative; +} + +.nav-dropdown-menu { + display: none; + position: absolute; + top: calc(100% + 0.5rem); + left: 0; + min-width: 160px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + z-index: 200; +} + +.nav-dropdown:hover .nav-dropdown-menu, +.nav-dropdown.open .nav-dropdown-menu { + display: block; +} + +.nav-dropdown-menu a { + display: block; + padding: 0.55rem 1rem; + border-radius: 0; +} + +/* Hamburger */ +.hamburger { + display: none; + flex-direction: column; + gap: 5px; + cursor: pointer; + padding: 0.5rem; + background: none; + border: none; +} + +.hamburger span { + display: block; + width: 22px; + height: 2px; + background: var(--text-muted); + border-radius: 2px; + transition: + transform 0.2s, + opacity 0.2s; +} + +/* ─── Main layout ──────────────────────────────────────── */ +main { + max-width: 800px; + margin: 2rem auto; + padding: 0 1rem; +} + +/* ─── Alerts ───────────────────────────────────────────── */ +.alert { + padding: 0.75rem 1rem; + border-radius: var(--radius); + margin-bottom: 1rem; + font-size: 0.875rem; + font-weight: 500; +} + +.alert-success { + background: rgba(86, 196, 137, 0.15); + color: var(--success); + border: 1px solid rgba(86, 196, 137, 0.3); +} + +.alert-error { + background: rgba(224, 90, 106, 0.15); + color: var(--danger); + border: 1px solid rgba(224, 90, 106, 0.3); +} + +/* ─── Card ─────────────────────────────────────────────── */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 2rem; +} + +.card h1 { + font-size: 1.4rem; + font-weight: 700; + margin-bottom: 1.5rem; + letter-spacing: -0.02em; +} + +.card h2 { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 1rem; +} + +/* ─── Forms ────────────────────────────────────────────── */ +form label { + display: block; + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 0.35rem; + margin-top: 1.1rem; +} + +form input, +form select, +form textarea { + width: 100%; + padding: 0.6rem 0.85rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.95rem; + font-family: inherit; + color: var(--text); + transition: + border-color 0.15s, + box-shadow 0.15s; +} + +form input::placeholder, +form textarea::placeholder { + color: var(--text-muted); +} + +form select option { + background: var(--surface-2); +} + +form input:focus, +form select:focus, +form textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(124, 111, 247, 0.25); +} + +/* ─── Buttons ──────────────────────────────────────────── */ +.btn, +button[type="submit"] { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin-top: 1.25rem; + padding: 0.6rem 1.4rem; + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius); + font-size: 0.9rem; + font-weight: 600; + font-family: inherit; + cursor: pointer; + text-decoration: none; + transition: + background 0.15s, + transform 0.1s; +} + +.btn:hover, +button[type="submit"]:hover { + background: var(--accent-hover); +} + +.btn:active, +button[type="submit"]:active { + transform: scale(0.98); +} + +.btn-danger { + background: var(--danger); +} + +.btn-danger:hover { + background: var(--danger-hover); +} + +.btn-sm { + padding: 0.3rem 0.75rem; + font-size: 0.8rem; + margin-top: 0; +} + +/* ─── Tabs ─────────────────────────────────────────────── */ +.tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--border); + padding-bottom: 0; +} + +.tab-btn { + background: none; + border: none; + border-bottom: 2px solid transparent; + padding: 0.5rem 1rem; + margin-bottom: -1px; + color: var(--text-muted); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + margin-top: 0; + transition: + color 0.15s, + border-color 0.15s; +} + +.tab-btn:hover { + color: var(--text); +} + +.tab-btn.active { + color: var(--accent-hover); + border-bottom-color: var(--accent); +} + +.tab-panel { + display: none; +} +.tab-panel.active { + display: block; +} + +/* ─── Result ───────────────────────────────────────────── */ +.result { + margin-top: 1.75rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); +} + +.result h2 { + font-size: 1rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.75rem; +} + +pre { + background: var(--surface-2); + border: 1px solid var(--border); + padding: 1rem; + border-radius: var(--radius); + white-space: pre-wrap; + word-break: break-word; + font-size: 0.9rem; + line-height: 1.7; + color: var(--text); +} + +.generated-image { + max-width: 100%; + border-radius: var(--radius); + margin-top: 0.5rem; + border: 1px solid var(--border); +} + +.generated-video { + max-width: 100%; + border-radius: var(--radius); + margin-top: 0.5rem; +} + +/* ─── Admin table ──────────────────────────────────────── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.stat-box { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1.25rem; +} + +.stat-box .stat-label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.stat-box .stat-value { + font-size: 2rem; + font-weight: 700; + color: var(--accent-hover); + line-height: 1.2; + margin-top: 0.25rem; +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +th { + text-align: left; + padding: 0.5rem 0.75rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + border-bottom: 1px solid var(--border); +} + +td { + padding: 0.65rem 0.75rem; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +tr:last-child td { + border-bottom: none; +} + +.role-badge { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.role-admin { + background: rgba(124, 111, 247, 0.2); + color: var(--accent-hover); +} + +.role-user { + background: rgba(139, 144, 184, 0.15); + color: var(--text-muted); +} + +.table-actions { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} + +/* ─── Loading overlay ──────────────────────────────────── */ +#loading-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(15, 17, 23, 0.75); + z-index: 1000; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 1rem; +} + +#loading-overlay.active { + display: flex; +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +.spinner-label { + color: var(--text-muted); + font-size: 0.9rem; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ─── Misc ─────────────────────────────────────────────── */ +.text-muted { + color: var(--text-muted); +} +.mt-1 { + margin-top: 0.5rem; +} +.mt-2 { + margin-top: 1rem; +} +.section-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 1rem; +} + +/* ─── Responsive ───────────────────────────────────────── */ +@media (max-width: 640px) { + .hamburger { + display: flex; + } + + .nav-links { + display: none; + flex-direction: column; + align-items: flex-start; + position: absolute; + top: 3.5rem; + left: 0; + right: 0; + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 0.75rem 1rem; + gap: 0.1rem; + z-index: 99; + } + + .nav-links.open { + display: flex; + } + + .nav-dropdown-menu { + position: static; + border: none; + background: none; + padding-left: 0.75rem; + } + + .card { + padding: 1.25rem; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } +} + nav { display: flex; align-items: center; diff --git a/frontend/app/templates/admin.html b/frontend/app/templates/admin.html new file mode 100644 index 0000000..70f93e1 --- /dev/null +++ b/frontend/app/templates/admin.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block title %}Admin — AI Allucanget{% endblock %} +{% block content %} +
+

Admin Dashboard

+ + {% if stats %} +
+
+
Total users
+
{{ stats.get('total_users', 0) }}
+
+
+
Active tokens
+
{{ stats.get('active_refresh_tokens', 0) }}
+
+
+
Admins
+
{{ stats.get('admin_users', 0) }}
+
+
+ {% endif %} + +

Users

+
+ + + + + + + + + + {% for u in users %} + + + + + + {% else %} + + {% endfor %} + +
EmailRoleActions
{{ u.email }} + {{ u.role }} + +
+ +
+ + +
+ + {% if u.id != session.get('user_id') %} +
+ +
+ {% endif %} +
+
No users found.
+
+
+{% endblock %} diff --git a/frontend/app/templates/base.html b/frontend/app/templates/base.html index 378d8ec..1d3cffd 100644 --- a/frontend/app/templates/base.html +++ b/frontend/app/templates/base.html @@ -1,36 +1,61 @@ - + - - - - {% block title %}AI Allucanget{% endblock %} - - - -
- +
-
- {% with messages = get_flashed_messages(with_categories=true) %} - {% for category, message in messages %} -
{{ message }}
- {% endfor %} - {% endwith %} +
+
+ Working… +
- {% block content %}{% endblock %} -
- +
+ {% with messages = get_flashed_messages(with_categories=true) %} {% for + category, message in messages %} +
{{ message }}
+ {% endfor %} {% endwith %} {% block content %}{% endblock %} +
+ + + diff --git a/frontend/app/templates/generate.html b/frontend/app/templates/generate.html index 7597f49..9141c4e 100644 --- a/frontend/app/templates/generate.html +++ b/frontend/app/templates/generate.html @@ -1,47 +1,9 @@ -{% extends "base.html" %} -{% block title %}Generate — AI Allucanget{% endblock %} -{% block content %} +{% extends "base.html" %} {% block title %}Generate — AI Allucanget{% endblock +%} {% block content %}

Generate

-
- - - - - - - - - - -
- - {% if error %} -
{{ error }}
- {% endif %} - - {% if result %} -
- {% if result.get('content') %} -

Result

-
{{ result.content }}
- {% elif result.get('images') %} -

Generated image{{ 's' if result.images|length > 1 }}

- {% for img in result.images %} - Generated image - {% endfor %} - {% elif result.get('status') %} -

Video job

-

Status: {{ result.status }}

- {% if result.get('video_url') %} - - {% endif %} - {% endif %} -
- {% endif %} +

+ Choose a generation type from the Generate menu above. +

{% endblock %} diff --git a/frontend/app/templates/generate_image.html b/frontend/app/templates/generate_image.html new file mode 100644 index 0000000..0a15db0 --- /dev/null +++ b/frontend/app/templates/generate_image.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Image Generation — AI Allucanget{% endblock %} +{% block content %} +
+

Image Generation

+
+ + + + + + + + + + + + + +
+ + {% if error %} +
{{ error }}
+ {% endif %} + + {% if result %} +
+

Generated image{{ 's' if result.images|length > 1 }}

+ {% for img in result.images %} + Generated image + {% if img.revised_prompt %} +

{{ img.revised_prompt }}

+ {% endif %} + {% endfor %} +
+ {% endif %} +
+{% endblock %} diff --git a/frontend/app/templates/generate_text.html b/frontend/app/templates/generate_text.html new file mode 100644 index 0000000..db48497 --- /dev/null +++ b/frontend/app/templates/generate_text.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Text Generation — AI Allucanget{% endblock %} +{% block content %} +
+

Text Generation

+
+ + + + + + + +
+ + {% if error %} +
{{ error }}
+ {% endif %} + + {% if result %} +
+

Result

+
{{ result.content }}
+ {% if result.usage %} +

+ Tokens: {{ result.usage.get('total_tokens', '—') }} +

+ {% endif %} +
+ {% endif %} +
+{% endblock %} diff --git a/frontend/app/templates/generate_video.html b/frontend/app/templates/generate_video.html new file mode 100644 index 0000000..8b7acba --- /dev/null +++ b/frontend/app/templates/generate_video.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block title %}Video Generation — AI Allucanget{% endblock %} +{% block content %} +
+

Video Generation

+ +
+
+ + +
+ + +
+
+ + + + + + + + + + + + +
+
+ + +
+
+ + + + + + + + + + + + + + + +
+
+
+ + {% if error %} +
{{ error }}
+ {% endif %} + + {% if result %} +
+

Video job

+

Status: {{ result.status }}

+ {% if result.get('video_url') %} + + {% else %} +

+ Video is being processed. Check back later. +

+ {% endif %} +
+ {% endif %} +
+{% endblock %} diff --git a/frontend/app/templates/profile.html b/frontend/app/templates/profile.html new file mode 100644 index 0000000..5be5361 --- /dev/null +++ b/frontend/app/templates/profile.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} {% block title %}Profile — AI Allucanget{% endblock %} +{% block content %} +
+

Your Profile

+ +

Account details

+

+ Current email: + {{ user.get('email', '') }} +  ·  Role: + {{ user.get('role', 'user') }} +

+ +

Update email

+
+ + + + +
+ +

Change password

+
+ + + + +
+
+{% endblock %}