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>
This commit is contained in:
2026-04-27 18:48:01 +02:00
parent f2a9f187f2
commit 53d2d2ffef
10 changed files with 1042 additions and 99 deletions
+137 -24
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
# ---------------------------------------------------------------------------
@@ -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/<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)
return render_template("generate.html", result=result, error=error)
+36
View File
@@ -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');
});
});
});
+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;
+68
View File
@@ -0,0 +1,68 @@
{% 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 %}
+37 -12
View File
@@ -1,19 +1,40 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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') }}">
<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 %}
<a href="{{ url_for('login') }}">Log in</a>
@@ -23,14 +44,18 @@
</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>
{% 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 %}
+35
View File
@@ -0,0 +1,35 @@
{% 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 %}
@@ -0,0 +1,87 @@
{% 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 %}