feat: add gallery page with image and video details, including upload and generation status

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-29 16:13:09 +02:00
parent d4421616e5
commit 951a653dc9
8 changed files with 477 additions and 3 deletions
+79
View File
@@ -186,6 +186,56 @@ async def list_generated_images(
] ]
@router.get("/images/{image_id}")
async def get_generated_image(
image_id: str,
current_user: dict = Depends(get_current_user),
) -> dict:
"""Return details for a single generated image."""
user_id = current_user.get("id") or current_user.get("sub")
conn = get_conn()
row = conn.execute(
"""SELECT id, model_id, prompt, image_data, created_at
FROM generated_images
WHERE id = ? AND user_id = ?""",
[image_id, user_id],
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Image not found")
return {
"id": str(row[0]),
"model_id": row[1],
"prompt": row[2],
"image_data": row[3],
"created_at": row[4].isoformat() if row[4] else None,
}
@router.get("/images/{image_id}")
async def get_generated_image(
image_id: str,
current_user: dict = Depends(get_current_user),
) -> dict:
"""Return details for a single generated image."""
user_id = current_user.get("id") or current_user.get("sub")
conn = get_conn()
row = conn.execute(
"""SELECT id, model_id, prompt, image_data, created_at
FROM generated_images
WHERE id = ? AND user_id = ?""",
[image_id, user_id],
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Image not found")
return {
"id": str(row[0]),
"model_id": row[1],
"prompt": row[2],
"image_data": row[3],
"created_at": row[4].isoformat() if row[4] else None,
}
@router.post("/video", response_model=VideoResponse) @router.post("/video", response_model=VideoResponse)
async def generate_video( async def generate_video(
body: VideoRequest, body: VideoRequest,
@@ -358,3 +408,32 @@ async def list_generated_videos(
} }
for r in rows for r in rows
] ]
@router.get("/videos/{video_id}")
async def get_generated_video(
video_id: str,
current_user: dict = Depends(get_current_user),
) -> dict:
"""Return details for a single video generation job."""
user_id = current_user.get("id") or current_user.get("sub")
conn = get_conn()
row = conn.execute(
"""SELECT id, job_id, model_id, prompt, polling_url, status, video_url, created_at, updated_at
FROM generated_videos
WHERE id = ? AND user_id = ?""",
[video_id, user_id],
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Video job not found")
return {
"id": str(row[0]),
"job_id": row[1],
"model_id": row[2],
"prompt": row[3],
"polling_url": row[4],
"status": row[5],
"video_url": row[6],
"created_at": row[7].isoformat() if row[7] else None,
"updated_at": row[8].isoformat() if row[8] else None,
}
+36 -3
View File
@@ -93,6 +93,36 @@ async def list_images(
] ]
@router.get("/{image_id}", status_code=status.HTTP_200_OK)
async def get_image_details(
image_id: str,
current_user: dict = Depends(get_current_user),
) -> dict:
"""Return metadata for a single uploaded image."""
conn = get_conn()
row = conn.execute(
"""
SELECT id, filename, content_type, size_bytes, created_at
FROM uploaded_images
WHERE id = ? AND user_id = ?
""",
[image_id, current_user["id"]],
).fetchone()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Image not found"
)
return {
"id": str(row[0]),
"filename": row[1],
"content_type": row[2],
"size_bytes": row[3],
"created_at": row[4].isoformat() if row[4] else None,
}
@router.get("/{image_id}/file", status_code=status.HTTP_200_OK) @router.get("/{image_id}/file", status_code=status.HTTP_200_OK)
async def serve_image( async def serve_image(
image_id: str, image_id: str,
@@ -106,12 +136,15 @@ async def serve_image(
).fetchone() ).fetchone()
if row is None: if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found.") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Image not found.")
if str(row[2]) != current_user["id"]: if str(row[2]) != current_user["id"]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied.") raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Access denied.")
file_path: str = row[0] file_path: str = row[0]
if not os.path.isfile(file_path): if not os.path.isfile(file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image file missing.") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Image file missing.")
return FileResponse(file_path, media_type=row[1]) return FileResponse(file_path, media_type=row[1])
+57
View File
@@ -187,6 +187,63 @@ def dashboard():
generated_videos=generated_videos) generated_videos=generated_videos)
@app.get("/gallery")
@login_required
def gallery():
token = session["access_token"]
# Fetch all content types
uploads_resp = _api("GET", "/images/", token=token)
uploads = uploads_resp.json() if uploads_resp.status_code == 200 else []
gen_images_resp = _api("GET", "/generate/images", token=token)
generated_images = gen_images_resp.json(
) if gen_images_resp.status_code == 200 else []
videos_resp = _api("GET", "/generate/videos", token=token)
videos = videos_resp.json() if videos_resp.status_code == 200 else []
# Separate pending videos
pending_videos = [v for v in videos if v.get(
"status") not in ("completed", "failed")]
completed_videos = [v for v in videos if v.get("status") == "completed"]
return render_template(
"gallery.html",
uploads=uploads,
generated_images=generated_images,
pending_videos=pending_videos,
completed_videos=completed_videos,
)
@app.get("/gallery/image/<image_id>")
@login_required
def image_detail(image_id: str):
token = session["access_token"]
resp = _api("GET", f"/generate/images/{image_id}", token=token)
image = resp.json() if resp.status_code == 200 else None
return render_template("image_detail.html", image=image)
@app.get("/gallery/video/<video_id>")
@login_required
def video_detail(video_id: str):
token = session["access_token"]
resp = _api("GET", f"/generate/videos/{video_id}", token=token)
video = resp.json() if resp.status_code == 200 else None
return render_template("video_detail.html", video=video)
@app.get("/gallery/upload/<image_id>")
@login_required
def upload_detail(image_id: str):
token = session["access_token"]
resp = _api("GET", f"/images/{image_id}", token=token)
image = resp.json() if resp.status_code == 200 else None
return render_template("upload_detail.html", image=image)
# ── Generate ────────────────────────────────────────────────────────────── # ── Generate ──────────────────────────────────────────────────────────────
@app.get("/images/<image_id>/file") @app.get("/images/<image_id>/file")
+1
View File
@@ -21,6 +21,7 @@
<div class="nav-links"> <div class="nav-links">
{% if session.get('access_token') %} {% if session.get('access_token') %}
<a href="{{ url_for('dashboard') }}">Dashboard</a> <a href="{{ url_for('dashboard') }}">Dashboard</a>
<a href="{{ url_for('gallery') }}">Gallery</a>
<a href="{{ url_for('generate_text') }}">Generate Text</a> <a href="{{ url_for('generate_text') }}">Generate Text</a>
<a href="{{ url_for('generate_image') }}">Generate Image</a> <a href="{{ url_for('generate_image') }}">Generate Image</a>
+159
View File
@@ -0,0 +1,159 @@
{% extends "base.html" %} {% block title %}My Gallery{% endblock %} {% block
content %}
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-6">My Gallery</h1>
<!-- Pending Creations -->
{% if pending_videos %}
<div class="mb-12">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
Pending Creations
</h2>
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
>
{% for video in pending_videos %}
<a
href="{{ url_for('video_detail', video_id=video.id) }}"
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
>
<div class="p-4">
<p class="font-bold text-lg truncate">{{ video.prompt }}</p>
<p class="text-sm text-gray-400">
Video Job Status:
<span class="font-semibold text-yellow-400"
>{{ video.status }}</span
>
</p>
<p class="text-xs text-gray-500 mt-2">
Started: {{ video.created_at | fromisoformat | humantime }}
</p>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Generated Images -->
<div class="mb-12">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
Generated Images
</h2>
{% if generated_images %}
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
>
{% for image in generated_images %}
<a
href="{{ url_for('image_detail', image_id=image.id) }}"
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
>
<img
src="{{ image.image_data }}"
alt="{{ image.prompt }}"
class="w-full h-48 object-cover"
/>
<div class="p-4">
<p class="text-sm truncate">{{ image.prompt }}</p>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-gray-400">
You haven't generated any images yet.
<a
href="{{ url_for('generate_image') }}"
class="text-blue-400 hover:underline"
>Generate one now</a
>.
</p>
{% endif %}
</div>
<!-- Generated Videos -->
<div class="mb-12">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
Generated Videos
</h2>
{% if completed_videos %}
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
>
{% for video in completed_videos %}
<a
href="{{ url_for('video_detail', video_id=video.id) }}"
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
>
<div class="w-full h-48 bg-black flex items-center justify-center">
<svg
class="w-12 h-12 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
</div>
<div class="p-4">
<p class="text-sm truncate">{{ video.prompt }}</p>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-gray-400">
You haven't generated any videos yet.
<a
href="{{ url_for('generate_video') }}"
class="text-blue-400 hover:underline"
>Generate one now</a
>.
</p>
{% endif %}
</div>
<!-- Uploaded Images -->
<div>
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
My Uploads
</h2>
{% if uploads %}
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
>
{% for image in uploads %}
<a
href="{{ url_for('upload_detail', image_id=image.id) }}"
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
>
<img
src="{{ url_for('serve_uploaded_image', image_id=image.id) }}"
alt="{{ image.filename }}"
class="w-full h-48 object-cover"
/>
<div class="p-4">
<p class="text-sm truncate">{{ image.filename }}</p>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-gray-400">You haven't uploaded any images.</p>
{% endif %}
</div>
</div>
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends "base.html" %} {% block title %}Generated Image{% endblock %} {%
block content %}
<div class="container mx-auto px-4 py-8">
<a
href="{{ url_for('gallery') }}"
class="text-blue-400 hover:underline mb-4 inline-block"
>&larr; Back to Gallery</a
>
{% if image %}
<h1 class="text-2xl font-bold mb-4">Generated Image</h1>
<div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden">
<img
src="{{ image.image_data }}"
alt="{{ image.prompt }}"
class="w-full object-contain"
/>
<div class="p-6">
<h2 class="text-xl font-semibold mb-2">Prompt</h2>
<p class="text-gray-300 bg-gray-900 p-3 rounded-md">{{ image.prompt }}</p>
<div class="mt-4 text-sm text-gray-400">
<p><strong>Model:</strong> {{ image.model_id }}</p>
<p>
<strong>Created:</strong> {{ image.created_at | fromisoformat |
humantime }}
</p>
</div>
</div>
</div>
{% else %}
<h1 class="text-2xl font-bold">Image not found</h1>
<p class="text-gray-400 mt-2">Could not find details for this image.</p>
{% endif %}
</div>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %} {% block title %}Uploaded Image{% endblock %} {% block
content %}
<div class="container mx-auto px-4 py-8">
<a
href="{{ url_for('gallery') }}"
class="text-blue-400 hover:underline mb-4 inline-block"
>&larr; Back to Gallery</a
>
{% if image %}
<h1 class="text-2xl font-bold mb-4">Uploaded Image</h1>
<div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden">
<img
src="{{ url_for('serve_uploaded_image', image_id=image.id) }}"
alt="{{ image.filename }}"
class="w-full object-contain"
/>
<div class="p-6">
<h2 class="text-xl font-semibold mb-2">Details</h2>
<div class="mt-4 text-sm text-gray-400">
<p><strong>Filename:</strong> {{ image.filename }}</p>
<p><strong>Content Type:</strong> {{ image.content_type }}</p>
<p>
<strong>Size:</strong> {{ (image.size_bytes / 1024) | round(2) }} KB
</p>
<p>
<strong>Uploaded:</strong> {{ image.created_at | fromisoformat |
humantime }}
</p>
</div>
</div>
</div>
{% else %}
<h1 class="text-2xl font-bold">Image not found</h1>
<p class="text-gray-400 mt-2">
Could not find details for this uploaded image.
</p>
{% endif %}
</div>
{% endblock %}
+70
View File
@@ -0,0 +1,70 @@
{% extends "base.html" %} {% block title %}Generated Video{% endblock %} {%
block content %}
<div class="container mx-auto px-4 py-8">
<a
href="{{ url_for('gallery') }}"
class="text-blue-400 hover:underline mb-4 inline-block"
>&larr; Back to Gallery</a
>
{% if video %}
<h1 class="text-2xl font-bold mb-4">Video Generation Job</h1>
<div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden">
{% if video.status == 'completed' and video.video_url %}
<video src="{{ video.video_url }}" controls class="w-full"></video>
{% elif video.status in ('queued', 'processing') and video.polling_url %}
<div
class="w-full bg-black aspect-video flex flex-col items-center justify-center p-6 text-center"
id="video-poll-status"
data-polling-url="{{ video.polling_url }}"
>
<p class="text-xl font-semibold">
Status: <strong id="poll-status-text">{{ video.status }}</strong>
</p>
<p class="text-gray-400 mt-2">
Your video is being processed. This page will update automatically when
it's ready.
</p>
<div class="spinner mt-4"></div>
</div>
{% elif video.status == 'failed' %}
<div
class="w-full bg-black aspect-video flex flex-col items-center justify-center p-6 text-center"
>
<p class="text-xl font-semibold text-red-500">Generation Failed</p>
<p class="text-gray-400 mt-2">
{{ video.error or 'An unknown error occurred.' }}
</p>
</div>
{% else %}
<div
class="w-full bg-black aspect-video flex flex-col items-center justify-center p-6 text-center"
>
<p class="text-xl font-semibold">Video Not Available</p>
<p class="text-gray-400 mt-2">Status: {{ video.status }}</p>
</div>
{% endif %}
<div class="p-6">
<h2 class="text-xl font-semibold mb-2">Prompt</h2>
<p class="text-gray-300 bg-gray-900 p-3 rounded-md">{{ video.prompt }}</p>
<div class="mt-4 text-sm text-gray-400">
<p><strong>Model:</strong> {{ video.model_id }}</p>
<p><strong>Job ID:</strong> <code>{{ video.job_id }}</code></p>
<p>
<strong>Created:</strong> {{ video.created_at | fromisoformat |
humantime }}
</p>
<p>
<strong>Last Update:</strong> {{ video.updated_at | fromisoformat |
humantime }}
</p>
</div>
</div>
</div>
{% else %}
<h1 class="text-2xl font-bold">Video job not found</h1>
<p class="text-gray-400 mt-2">Could not find details for this video job.</p>
{% endif %}
</div>
{% endblock %}