Compare commits

...

2 Commits

Author SHA1 Message Date
zwitschi 4edadd7623 Refactor code for improved readability and consistency across templates and frontend logic
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 18:48:22 +02:00
zwitschi 53d2d2ffef Add admin features, user profile management, and generation capabilities
- Implemented admin dashboard with user management features including role assignment and deletion.
- Added user profile page for updating email and password.
- Created separate routes and templates for text, image, and video generation with appropriate forms.
- Enhanced navigation with dropdown for generation options and loading overlay for better user experience.
- Introduced comprehensive error handling and user feedback through alerts.
- Updated styles for improved UI consistency and responsiveness.

Co-authored-by: Copilot <copilot@github.com>
2026-04-27 18:48:01 +02:00
11 changed files with 1309 additions and 144 deletions
+142 -26
View File
@@ -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
# ---------------------------------------------------------------------------
@@ -58,11 +70,17 @@ def login():
if request.method == "POST":
email = request.form["email"]
password = request.form["password"]
resp = _api("POST", "/auth/login", json={"email": email, "password": 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")
@@ -73,7 +91,8 @@ def register():
if request.method == "POST":
email = request.form["email"]
password = request.form["password"]
resp = _api("POST", "/auth/register", json={"email": email, "password": 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"))
@@ -104,33 +123,130 @@ 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)
return render_template("generate.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/<user_id>/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/<user_id>/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)
+40
View File
@@ -0,0 +1,40 @@
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");
});
});
});
+528 -4
View File
@@ -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;
+80
View File
@@ -0,0 +1,80 @@
{% extends "base.html" %} {% block title %}Admin — AI Allucanget{% endblock %}
{% block content %}
<div class="card">
<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>
</div>
{% endblock %}
+53 -28
View File
@@ -1,36 +1,61 @@
<!DOCTYPE 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 %}AI Allucanget{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header>
<nav>
<a href="{{ url_for('index') }}" class="brand">AI Allucanget</a>
<div class="nav-links">
{% if session.get('access_token') %}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}AI Allucanget{% endblock %}</title>
<link
rel="stylesheet"
href="{{ url_for('static', filename='style.css') }}"
/>
</head>
<body>
<header>
<nav>
<a href="{{ url_for('index') }}" class="brand">AI Allucanget</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') }}">Dashboard</a>
<a href="{{ url_for('generate') }}">Generate</a>
<div class="nav-dropdown">
<a href="{{ url_for('generate_text') }}">Generate ▾</a>
<div class="nav-dropdown-menu">
<a href="{{ url_for('generate_text') }}">Text</a>
<a href="{{ url_for('generate_image') }}">Image</a>
<a href="{{ url_for('generate_video') }}">Video</a>
</div>
</div>
<a href="{{ url_for('profile') }}">Profile</a>
{% if session.get('user_role') == 'admin' %}
<a href="{{ url_for('admin') }}">Admin</a>
{% endif %}
<a href="{{ url_for('logout') }}">Log out</a>
{% else %}
{% else %}
<a href="{{ url_for('login') }}">Log in</a>
<a href="{{ url_for('register') }}">Register</a>
{% endif %}
</div>
</nav>
</header>
{% endif %}
</div>
</nav>
</header>
<main>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
<div id="loading-overlay">
<div class="spinner"></div>
<span class="spinner-label">Working…</span>
</div>
{% block content %}{% endblock %}
</main>
</body>
<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>
+5 -43
View File
@@ -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 %}
<div class="card">
<h1>Generate</h1>
<form method="post">
<label for="type">Type</label>
<select id="type" name="type">
<option value="text">Text</option>
<option value="image">Image</option>
<option value="video">Video</option>
</select>
<label for="model">Model</label>
<input id="model" name="model" type="text" required placeholder="e.g. openai/gpt-4o">
<label for="prompt">Prompt</label>
<textarea id="prompt" name="prompt" rows="4" required placeholder="Describe what you want…"></textarea>
<button type="submit">Generate</button>
</form>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% if result %}
<div class="result">
{% if result.get('content') %}
<h2>Result</h2>
<pre>{{ result.content }}</pre>
{% elif result.get('images') %}
<h2>Generated image{{ 's' if result.images|length > 1 }}</h2>
{% for img in result.images %}
<img src="{{ img.url }}" alt="Generated image" class="generated-image">
{% endfor %}
{% elif result.get('status') %}
<h2>Video job</h2>
<p>Status: <strong>{{ result.status }}</strong></p>
{% if result.get('video_url') %}
<video src="{{ result.video_url }}" controls class="generated-video"></video>
{% endif %}
{% endif %}
</div>
{% endif %}
<p class="text-muted">
Choose a generation type from the Generate menu above.
</p>
</div>
{% endblock %}
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Image Generation — AI Allucanget{% endblock %}
{% block content %}
<div class="card">
<h1>Image Generation</h1>
<form method="post">
<label for="model">Model</label>
<input id="model" name="model" type="text" required
placeholder="e.g. openai/dall-e-3"
value="{{ request.form.get('model', '') }}">
<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="size">Size</label>
<select id="size" name="size">
<option value="1024x1024" {% if request.form.get('size','1024x1024')=='1024x1024' %}selected{% endif %}>1024×1024</option>
<option value="1792x1024" {% if request.form.get('size')=='1792x1024' %}selected{% endif %}>1792×1024 (landscape)</option>
<option value="1024x1792" {% if request.form.get('size')=='1024x1792' %}selected{% endif %}>1024×1792 (portrait)</option>
<option value="512x512" {% if request.form.get('size')=='512x512' %}selected{% endif %}>512×512</option>
</select>
<label for="n">Number of images</label>
<select id="n" name="n">
<option value="1" {% if request.form.get('n','1')=='1' %}selected{% endif %}>1</option>
<option value="2" {% if request.form.get('n')=='2' %}selected{% endif %}>2</option>
<option value="4" {% if request.form.get('n')=='4' %}selected{% endif %}>4</option>
</select>
<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 %}
<img src="{{ img.url }}" alt="Generated image" class="generated-image">
{% 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 %}
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %} {% block title %}Text Generation — AI Allucanget{%
endblock %} {% block content %}
<div class="card">
<h1>Text Generation</h1>
<form method="post">
<label for="model">Model</label>
<input
id="model"
name="model"
type="text"
required
placeholder="e.g. openai/gpt-4o"
value="{{ request.form.get('model', '') }}"
/>
<label for="prompt">Prompt</label>
<textarea
id="prompt"
name="prompt"
rows="5"
required
placeholder="Describe what you want…"
>
{{ request.form.get('prompt', '') }}</textarea
>
<button type="submit">Generate text</button>
</form>
{% if error %}
<div class="alert alert-error mt-2">{{ error }}</div>
{% endif %} {% if result %}
<div class="result">
<h2>Result</h2>
<pre>{{ result.content }}</pre>
{% if result.usage %}
<p class="text-muted mt-1" style="font-size: 0.8rem">
Tokens: {{ result.usage.get('total_tokens', '—') }}
</p>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
+121
View File
@@ -0,0 +1,121 @@
{% extends "base.html" %} {% block title %}Video Generation — AI Allucanget{%
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>
<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 '' }}"
/>
<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>
<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>
<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 '' }}"
/>
<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>
<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>Status: <strong>{{ result.status }}</strong></p>
{% if result.get('video_url') %}
<video
src="{{ result.video_url }}"
controls
class="generated-video"
></video>
{% else %}
<p class="text-muted mt-1" style="font-size: 0.875rem">
Video is being processed. Check back later.
</p>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "base.html" %} {% block title %}Profile — AI Allucanget{% 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 %}
+203 -43
View File
@@ -17,13 +17,21 @@ def client():
yield c
def _mock_response(status_code: int, json_data: dict) -> MagicMock:
def _mock_response(status_code: int, json_data) -> MagicMock:
m = MagicMock()
m.status_code = status_code
m.json.return_value = json_data
return m
def _set_auth(client, role: str = "user"):
with client.session_transaction() as sess:
sess["access_token"] = "tok"
sess["refresh_token"] = "ref"
sess["user_role"] = role
sess["user_email"] = "u@example.com"
# ---------------------------------------------------------------------------
# Index redirect
# ---------------------------------------------------------------------------
@@ -35,9 +43,7 @@ def test_index_redirects_to_login(client):
def test_index_redirects_to_dashboard_when_logged_in(client):
with client.session_transaction() as sess:
sess["access_token"] = "tok"
sess["refresh_token"] = "ref"
_set_auth(client)
resp = client.get("/")
assert resp.status_code == 302
assert "/dashboard" in resp.headers["Location"]
@@ -54,17 +60,34 @@ def test_login_page_renders(client):
def test_login_success(client):
mock = _mock_response(200, {"access_token": "acc", "refresh_token": "ref"})
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.post("/login", data={"email": "u@example.com", "password": "secret"})
login_mock = _mock_response(
200, {"access_token": "acc", "refresh_token": "ref"})
me_mock = _mock_response(
200, {"id": "1", "email": "u@example.com", "role": "user"})
with patch("frontend.app.main.httpx.request", side_effect=[login_mock, me_mock]):
resp = client.post(
"/login", data={"email": "u@example.com", "password": "secret"})
assert resp.status_code == 302
assert "/dashboard" in resp.headers["Location"]
def test_login_stores_role_in_session(client):
login_mock = _mock_response(
200, {"access_token": "acc", "refresh_token": "ref"})
me_mock = _mock_response(
200, {"id": "1", "email": "admin@example.com", "role": "admin"})
with patch("frontend.app.main.httpx.request", side_effect=[login_mock, me_mock]):
client.post(
"/login", data={"email": "admin@example.com", "password": "secret"})
with client.session_transaction() as sess:
assert sess["user_role"] == "admin"
def test_login_failure_shows_error(client):
mock = _mock_response(401, {"detail": "Invalid credentials."})
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.post("/login", data={"email": "u@example.com", "password": "wrong"})
resp = client.post(
"/login", data={"email": "u@example.com", "password": "wrong"})
assert resp.status_code == 200
assert b"Invalid email or password" in resp.data
@@ -80,9 +103,11 @@ def test_register_page_renders(client):
def test_register_success_redirects_to_login(client):
mock = _mock_response(201, {"id": "abc", "email": "u@example.com", "role": "user"})
mock = _mock_response(
201, {"id": "abc", "email": "u@example.com", "role": "user"})
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.post("/register", data={"email": "u@example.com", "password": "secret123"})
resp = client.post(
"/register", data={"email": "u@example.com", "password": "secret123"})
assert resp.status_code == 302
assert "/login" in resp.headers["Location"]
@@ -90,7 +115,8 @@ def test_register_success_redirects_to_login(client):
def test_register_duplicate_shows_error(client):
mock = _mock_response(409, {"detail": "Email already registered."})
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.post("/register", data={"email": "dup@example.com", "password": "secret123"})
resp = client.post(
"/register", data={"email": "dup@example.com", "password": "secret123"})
assert resp.status_code == 200
assert b"Email already registered" in resp.data
@@ -100,9 +126,7 @@ def test_register_duplicate_shows_error(client):
# ---------------------------------------------------------------------------
def test_logout_clears_session_and_redirects(client):
with client.session_transaction() as sess:
sess["access_token"] = "tok"
sess["refresh_token"] = "ref"
_set_auth(client)
mock = _mock_response(204, {})
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.get("/logout")
@@ -123,9 +147,9 @@ def test_dashboard_requires_login(client):
def test_dashboard_renders_user_info(client):
with client.session_transaction() as sess:
sess["access_token"] = "tok"
mock = _mock_response(200, {"id": "1", "email": "u@example.com", "role": "user"})
_set_auth(client)
mock = _mock_response(
200, {"id": "1", "email": "u@example.com", "role": "user"})
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.get("/dashboard")
assert resp.status_code == 200
@@ -133,57 +157,193 @@ def test_dashboard_renders_user_info(client):
# ---------------------------------------------------------------------------
# Generate
# Generate — redirect + separate pages
# ---------------------------------------------------------------------------
def test_generate_page_requires_login(client):
def test_generate_redirects_to_text(client):
_set_auth(client)
resp = client.get("/generate")
assert resp.status_code == 302
assert "/generate/text" in resp.headers["Location"]
def test_generate_text_page_renders(client):
_set_auth(client)
resp = client.get("/generate/text")
assert resp.status_code == 200
assert b"Text Generation" in resp.data
def test_generate_text_requires_login(client):
resp = client.get("/generate/text")
assert resp.status_code == 302
assert "/login" in resp.headers["Location"]
def test_generate_page_renders(client):
with client.session_transaction() as sess:
sess["access_token"] = "tok"
resp = client.get("/generate")
assert resp.status_code == 200
assert b"Generate" in resp.data
def test_generate_text_success(client):
with client.session_transaction() as sess:
sess["access_token"] = "tok"
mock = _mock_response(200, {"id": "g1", "model": "openai/gpt-4o", "content": "Hello world", "usage": None})
_set_auth(client)
mock = _mock_response(
200, {"id": "g1", "model": "openai/gpt-4o", "content": "Hello world", "usage": None})
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.post("/generate", data={
"type": "text", "model": "openai/gpt-4o", "prompt": "Say hello"
})
resp = client.post(
"/generate/text", data={"model": "openai/gpt-4o", "prompt": "Say hello"})
assert resp.status_code == 200
assert b"Hello world" in resp.data
def test_generate_image_page_renders(client):
_set_auth(client)
resp = client.get("/generate/image")
assert resp.status_code == 200
assert b"Image Generation" in resp.data
def test_generate_image_success(client):
with client.session_transaction() as sess:
sess["access_token"] = "tok"
_set_auth(client)
mock = _mock_response(200, {
"id": "g2", "model": "openai/dall-e-3",
"images": [{"url": "https://example.com/img.png", "revised_prompt": None, "b64_json": None}]
})
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.post("/generate", data={
"type": "image", "model": "openai/dall-e-3", "prompt": "A cat"
resp = client.post("/generate/image", data={
"model": "openai/dall-e-3", "prompt": "A cat", "n": "1", "size": "1024x1024"
})
assert resp.status_code == 200
assert b"example.com/img.png" in resp.data
def test_generate_upstream_error_shows_message(client):
with client.session_transaction() as sess:
sess["access_token"] = "tok"
mock = _mock_response(502, {"detail": "OpenRouter error: timeout"})
def test_generate_video_page_renders(client):
_set_auth(client)
resp = client.get("/generate/video")
assert resp.status_code == 200
assert b"Video Generation" in resp.data
def test_generate_video_text_mode(client):
_set_auth(client)
mock = _mock_response(
200, {"id": "v1", "model": "openai/sora-2-pro", "status": "queued"})
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.post("/generate", data={
"type": "text", "model": "openai/gpt-4o", "prompt": "Hi"
resp = client.post("/generate/video", data={
"mode": "text", "model": "openai/sora-2-pro",
"prompt": "A sunset", "aspect_ratio": "16:9"
})
assert resp.status_code == 200
assert b"queued" in resp.data
def test_generate_video_image_mode(client):
_set_auth(client)
mock = _mock_response(
200, {"id": "v2", "model": "openai/sora-2-pro", "status": "processing"})
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.post("/generate/video", data={
"mode": "image", "model": "openai/sora-2-pro",
"image_url": "https://example.com/img.png",
"prompt": "Pan right", "aspect_ratio": "16:9"
})
assert resp.status_code == 200
assert b"processing" in resp.data
def test_generate_upstream_error_shows_message(client):
_set_auth(client)
mock = _mock_response(502, {"detail": "OpenRouter error: timeout"})
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.post(
"/generate/text", data={"model": "openai/gpt-4o", "prompt": "Hi"})
assert resp.status_code == 200
assert b"OpenRouter error" in resp.data
# ---------------------------------------------------------------------------
# Admin
# ---------------------------------------------------------------------------
def test_admin_requires_login(client):
resp = client.get("/admin")
assert resp.status_code == 302
assert "/login" in resp.headers["Location"]
def test_admin_requires_admin_role(client):
_set_auth(client, role="user")
resp = client.get("/admin")
assert resp.status_code == 302
assert "/dashboard" in resp.headers["Location"]
def test_admin_page_renders(client):
_set_auth(client, role="admin")
stats_mock = _mock_response(
200, {"total_users": 5, "active_refresh_tokens": 3, "admin_users": 1})
users_mock = _mock_response(200, [
{"id": "u1", "email": "a@example.com", "role": "admin"},
{"id": "u2", "email": "b@example.com", "role": "user"},
])
with patch("frontend.app.main.httpx.request", side_effect=[stats_mock, users_mock]):
resp = client.get("/admin")
assert resp.status_code == 200
assert b"a@example.com" in resp.data
assert b"b@example.com" in resp.data
def test_admin_set_role(client):
_set_auth(client, role="admin")
mock = _mock_response(
200, {"id": "u2", "email": "b@example.com", "role": "admin"})
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.post("/admin/users/u2/role", data={"role": "admin"})
assert resp.status_code == 302
assert "/admin" in resp.headers["Location"]
def test_admin_delete_user(client):
_set_auth(client, role="admin")
mock = _mock_response(204, {})
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.post("/admin/users/u2/delete")
assert resp.status_code == 302
assert "/admin" in resp.headers["Location"]
# ---------------------------------------------------------------------------
# Profile
# ---------------------------------------------------------------------------
def test_profile_requires_login(client):
resp = client.get("/users/profile")
assert resp.status_code == 302
assert "/login" in resp.headers["Location"]
def test_profile_page_renders(client):
_set_auth(client)
mock = _mock_response(
200, {"id": "1", "email": "u@example.com", "role": "user"})
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.get("/users/profile")
assert resp.status_code == 200
assert b"Profile" in resp.data
assert b"u@example.com" in resp.data
def test_profile_update_email(client):
_set_auth(client)
mock = _mock_response(
200, {"id": "1", "email": "new@example.com", "role": "user"})
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.post(
"/users/profile", data={"email": "new@example.com", "password": ""})
assert resp.status_code == 302
assert "/users/profile" in resp.headers["Location"]
def test_profile_update_failure(client):
_set_auth(client)
mock = _mock_response(422, {"detail": "Invalid email."})
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.post(
"/users/profile", data={"email": "bad", "password": ""})
# redirects regardless, flash message shown on next GET
assert resp.status_code == 302