feat(ui): add web UI with Jinja2 templates and Gitea themes
- Add Jinja2 templates with data-testid attributes for testing - Create light/dark themes based on Gitea color scheme - Add theme switching with localStorage persistence - Create base CSS, components, and layout styles - Add mock web routes for UI demonstration - Register web router and static files in main.py - Add data-testid requirements to AGENTS.md - Install jinja2 dependency
This commit is contained in:
16
app/main.py
16
app/main.py
@@ -12,6 +12,8 @@ from dishka import make_async_container
|
||||
from dishka.integrations.fastapi import setup_dishka
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.infrastructure import close_db, init_db, register_exception_handlers, settings
|
||||
from app.infrastructure.di.providers import (
|
||||
@@ -22,6 +24,7 @@ from app.infrastructure.di.providers import (
|
||||
UseCaseProvider,
|
||||
)
|
||||
from app.presentation import router
|
||||
from app.presentation.web import router as web_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -77,6 +80,19 @@ def app_factory() -> FastAPI:
|
||||
)
|
||||
|
||||
app.include_router(router, prefix="/api")
|
||||
app.include_router(web_router)
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root_redirect() -> HTMLResponse:
|
||||
"""Redirect root URL to web UI.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with redirect to web interface.
|
||||
"""
|
||||
return HTMLResponse(
|
||||
content='<meta http-equiv="refresh" content="0;url=/web/">', status_code=200
|
||||
)
|
||||
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check() -> dict[str, str]:
|
||||
|
||||
31
app/presentation/templates/base.html
Normal file
31
app/presentation/templates/base.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" data-testid="html-root">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="{% block meta_description %}Blog - A modern blogging platform{% endblock %}">
|
||||
<title data-testid="page-title">{% block title %}Blog{% endblock %}</title>
|
||||
|
||||
<link rel="stylesheet" href="/static/css/themes/theme-light.css" data-testid="theme-light-stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/themes/theme-dark.css" data-testid="theme-dark-stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/base.css" data-testid="base-stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/components.css" data-testid="components-stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/layout.css" data-testid="layout-stylesheet">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body data-testid="body">
|
||||
{% include "partials/header.html" %}
|
||||
|
||||
<main class="main-wrapper" data-testid="main-content">
|
||||
<div class="container" data-testid="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{% include "partials/footer.html" %}
|
||||
|
||||
<script src="/static/js/theme.js" data-testid="theme-script"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
83
app/presentation/templates/pages/index.html
Normal file
83
app/presentation/templates/pages/index.html
Normal file
@@ -0,0 +1,83 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Blog - Home{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-header" data-testid="page-header-home">
|
||||
<div class="page-header-flex">
|
||||
<div data-testid="page-header-content">
|
||||
<h1 class="page-title" data-testid="page-title-home">Latest Posts</h1>
|
||||
<p class="page-subtitle" data-testid="page-subtitle-home">Discover stories, thinking, and expertise from writers on any topic.</p>
|
||||
</div>
|
||||
<a href="/posts/new" class="btn btn-primary" data-testid="btn-create-post-header">
|
||||
Write a Post
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if posts %}
|
||||
<section class="post-list" data-testid="post-list">
|
||||
{% for post in posts %}
|
||||
<article class="card post-card" data-testid="post-card-{{ post.id }}">
|
||||
<div class="post-card-header" data-testid="post-card-header-{{ post.id }}">
|
||||
<h2 class="post-card-title" data-testid="post-title-{{ post.id }}">
|
||||
<a href="/posts/{{ post.id }}" data-testid="post-title-link-{{ post.id }}">{{ post.title }}</a>
|
||||
</h2>
|
||||
{% if post.published %}
|
||||
<span class="badge badge-success" data-testid="post-status-{{ post.id }}">Published</span>
|
||||
{% else %}
|
||||
<span class="badge" data-testid="post-status-{{ post.id }}">Draft</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="post-card-meta" data-testid="post-meta-{{ post.id }}">
|
||||
<span class="post-card-meta-item" data-testid="post-author-{{ post.id }}">
|
||||
<span class="avatar avatar-sm" data-testid="post-author-avatar-{{ post.id }}">{{ post.author_id[0]|upper }}</span>
|
||||
<span data-testid="post-author-name-{{ post.id }}">{{ post.author_id }}</span>
|
||||
</span>
|
||||
<span class="post-card-meta-item" data-testid="post-date-{{ post.id }}">
|
||||
{{ post.created_at.strftime('%B %d, %Y') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="post-card-content" data-testid="post-content-preview-{{ post.id }}">
|
||||
{{ post.content.value[:200] }}{% if post.content.value|length > 200 %}...{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="post-card-footer" data-testid="post-card-footer-{{ post.id }}">
|
||||
<div class="post-card-tags" data-testid="post-tags-{{ post.id }}">
|
||||
{% for tag in post.tags %}
|
||||
<span class="tag" data-testid="post-tag-{{ post.id }}-{{ loop.index }}">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<a href="/posts/{{ post.id }}" class="btn btn-sm" data-testid="btn-read-more-{{ post.id }}">Read more</a>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<nav class="pagination" data-testid="pagination" aria-label="Pagination">
|
||||
{% if has_prev %}
|
||||
<a href="/?page={{ current_page - 1 }}" class="pagination-item" data-testid="pagination-prev">Previous</a>
|
||||
{% else %}
|
||||
<span class="pagination-item disabled" data-testid="pagination-prev">Previous</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="pagination-item active" data-testid="pagination-current">{{ current_page }}</span>
|
||||
|
||||
{% if has_next %}
|
||||
<a href="/?page={{ current_page + 1 }}" class="pagination-item" data-testid="pagination-next">Next</a>
|
||||
{% else %}
|
||||
<span class="pagination-item disabled" data-testid="pagination-next">Next</span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state" data-testid="empty-state">
|
||||
<div class="empty-state-icon" data-testid="empty-state-icon">📝</div>
|
||||
<h3 data-testid="empty-state-title">No posts yet</h3>
|
||||
<p data-testid="empty-state-description">Be the first to write a post!</p>
|
||||
<a href="/posts/new" class="btn btn-primary" data-testid="btn-create-first-post">Create your first post</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
58
app/presentation/templates/pages/post_detail.html
Normal file
58
app/presentation/templates/pages/post_detail.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }} - Blog{% endblock %}
|
||||
{% block meta_description %}{{ post.content.value[:160] }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="post-detail" data-testid="post-detail">
|
||||
<header class="post-detail-header" data-testid="post-detail-header">
|
||||
<h1 class="post-detail-title" data-testid="post-detail-title">{{ post.title }}</h1>
|
||||
|
||||
<div class="post-detail-meta" data-testid="post-detail-meta">
|
||||
<span class="post-card-meta-item" data-testid="post-detail-author">
|
||||
<span class="avatar avatar-sm" data-testid="post-detail-author-avatar">{{ post.author_id[0]|upper }}</span>
|
||||
<span data-testid="post-detail-author-name">{{ post.author_id }}</span>
|
||||
</span>
|
||||
<span class="post-card-meta-item" data-testid="post-detail-date">
|
||||
{{ post.created_at.strftime('%B %d, %Y') }}
|
||||
</span>
|
||||
{% if post.published %}
|
||||
<span class="badge badge-success" data-testid="post-detail-status">Published</span>
|
||||
{% else %}
|
||||
<span class="badge" data-testid="post-detail-status">Draft</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="post-detail-content" data-testid="post-detail-content">
|
||||
{{ post.content.value|nl2br }}
|
||||
</div>
|
||||
|
||||
<footer class="post-detail-footer" data-testid="post-detail-footer">
|
||||
<div class="post-detail-tags" data-testid="post-detail-tags">
|
||||
{% for tag in post.tags %}
|
||||
<span class="tag" data-testid="post-detail-tag-{{ loop.index }}">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="divider" data-testid="post-detail-divider"></div>
|
||||
|
||||
<div class="flex justify-between items-center" data-testid="post-detail-actions">
|
||||
<a href="/" class="btn" data-testid="btn-back-to-posts">
|
||||
← Back to posts
|
||||
</a>
|
||||
|
||||
<div class="flex gap-2" data-testid="post-detail-edit-actions">
|
||||
<a href="/posts/{{ post.id }}/edit" class="btn" data-testid="btn-edit-post">
|
||||
Edit
|
||||
</a>
|
||||
<form action="/posts/{{ post.id }}/delete" method="POST" style="display: inline;" data-testid="form-delete-post">
|
||||
<button type="submit" class="btn btn-danger" data-testid="btn-delete-post" onclick="return confirm('Are you sure you want to delete this post?');">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
{% endblock %}
|
||||
99
app/presentation/templates/pages/post_form.html
Normal file
99
app/presentation/templates/pages/post_form.html
Normal file
@@ -0,0 +1,99 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if is_edit %}Edit Post{% else %}New Post{% endif %} - Blog{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-header" data-testid="page-header-form">
|
||||
<h1 class="page-title" data-testid="page-title-form">
|
||||
{% if is_edit %}Edit Post{% else %}Create New Post{% endif %}
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="{% if is_edit %}/posts/{{ post.id }}/edit{% else %}/posts/new{% endif %}"
|
||||
class="card"
|
||||
data-testid="form-post"
|
||||
>
|
||||
<div class="card-body" data-testid="form-post-body">
|
||||
<div class="form-group" data-testid="form-group-title">
|
||||
<label for="title" class="form-label form-label-required" data-testid="label-title">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
class="input input-lg"
|
||||
value="{% if post %}{{ post.title.value }}{% endif %}"
|
||||
placeholder="Enter post title"
|
||||
required
|
||||
data-testid="input-title"
|
||||
>
|
||||
<span class="form-hint" data-testid="hint-title">A catchy title for your post</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group" data-testid="form-group-content">
|
||||
<label for="content" class="form-label form-label-required" data-testid="label-content">
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
class="textarea"
|
||||
rows="12"
|
||||
placeholder="Write your post content here..."
|
||||
required
|
||||
data-testid="textarea-content"
|
||||
>{% if post %}{{ post.content.value }}{% endif %}</textarea>
|
||||
<span class="form-hint" data-testid="hint-content">The main content of your post. Markdown is supported.</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group" data-testid="form-group-tags">
|
||||
<label for="tags" class="form-label" data-testid="label-tags">
|
||||
Tags
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
name="tags"
|
||||
class="input"
|
||||
value="{% if post %}{{ post.tags|join(', ') }}{% endif %}"
|
||||
placeholder="python, fastapi, tutorial"
|
||||
data-testid="input-tags"
|
||||
>
|
||||
<span class="form-hint" data-testid="hint-tags">Comma-separated list of tags</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group" data-testid="form-group-published">
|
||||
<label class="form-label" data-testid="label-published">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="published"
|
||||
value="true"
|
||||
{% if post and post.published %}checked{% endif %}
|
||||
data-testid="checkbox-published"
|
||||
>
|
||||
Publish immediately
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer" data-testid="form-post-footer">
|
||||
<div class="flex justify-between items-center" data-testid="form-actions">
|
||||
<a href="{% if is_edit %}/posts/{{ post.id }}{% else %}/{% endif %}" class="btn" data-testid="btn-cancel">
|
||||
Cancel
|
||||
</a>
|
||||
|
||||
<div class="flex gap-2" data-testid="form-submit-actions">
|
||||
<button type="submit" name="action" value="draft" class="btn" data-testid="btn-save-draft">
|
||||
Save as Draft
|
||||
</button>
|
||||
<button type="submit" name="action" value="publish" class="btn btn-primary" data-testid="btn-publish-post">
|
||||
{% if is_edit %}Update Post{% else %}Publish Post{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
14
app/presentation/templates/partials/footer.html
Normal file
14
app/presentation/templates/partials/footer.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<footer class="site-footer" data-testid="site-footer">
|
||||
<div class="container" data-testid="footer-container">
|
||||
<div class="footer-copyright" data-testid="footer-copyright">
|
||||
<span data-testid="copyright-text">© 2026 Blog. All rights reserved.</span>
|
||||
</div>
|
||||
|
||||
<nav class="footer-links" data-testid="footer-nav" aria-label="Footer navigation">
|
||||
<a href="/about" class="footer-link" data-testid="footer-link-about">About</a>
|
||||
<a href="/privacy" class="footer-link" data-testid="footer-link-privacy">Privacy</a>
|
||||
<a href="/terms" class="footer-link" data-testid="footer-link-terms">Terms</a>
|
||||
<a href="/api/docs" class="footer-link" data-testid="footer-link-api">API</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
35
app/presentation/templates/partials/header.html
Normal file
35
app/presentation/templates/partials/header.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<header class="site-header" data-testid="site-header">
|
||||
<div class="container" data-testid="header-container">
|
||||
<a href="/" class="site-logo" data-testid="nav-logo">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="logo-icon">
|
||||
<rect width="32" height="32" rx="6" fill="var(--color-primary)"/>
|
||||
<path d="M8 12h16M8 16h12M8 20h8" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span data-testid="logo-text">Blog</span>
|
||||
</a>
|
||||
|
||||
{% include "partials/nav.html" %}
|
||||
|
||||
<div class="header-actions" data-testid="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="theme-toggle"
|
||||
data-testid="theme-toggle"
|
||||
aria-label="Toggle theme"
|
||||
title="Toggle theme"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="theme-light-icon" style="display: none;">
|
||||
<path d="M10 2v2M10 16v2M4.22 4.22l1.42 1.42M14.36 14.36l1.42 1.42M2 10h2M16 10h2M4.22 15.78l1.42-1.42M14.36 5.64l1.42-1.42" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="10" cy="10" r="4" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="theme-dark-icon" style="display: none;">
|
||||
<path d="M18 10.79A9 9 0 1 1 9.21 2 7 7 0 0 0 18 10.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<a href="/posts/new" class="btn btn-primary btn-sm" data-testid="btn-create-post">
|
||||
New Post
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
11
app/presentation/templates/partials/nav.html
Normal file
11
app/presentation/templates/partials/nav.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<nav class="main-nav" data-testid="main-nav" aria-label="Main navigation">
|
||||
<a href="/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="nav-link-home">
|
||||
Home
|
||||
</a>
|
||||
<a href="/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="nav-link-posts">
|
||||
Posts
|
||||
</a>
|
||||
<a href="/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="nav-link-about">
|
||||
About
|
||||
</a>
|
||||
</nav>
|
||||
13
app/presentation/web/__init__.py
Normal file
13
app/presentation/web/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Web UI layer for blog application.
|
||||
|
||||
This package provides HTML endpoints and templates for the blog web interface,
|
||||
separate from the JSON API layer. Uses Jinja2 templates with Gitea-inspired
|
||||
theme support and comprehensive data-testid attributes for testing.
|
||||
|
||||
The web layer follows the same DDD principles as the API layer and will
|
||||
be integrated with use cases in future iterations.
|
||||
"""
|
||||
|
||||
from app.presentation.web.routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
327
app/presentation/web/routes.py
Normal file
327
app/presentation/web/routes.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""Web UI routes for blog application.
|
||||
|
||||
This module provides HTML endpoints for the blog web interface.
|
||||
Currently uses mock data for demonstration purposes.
|
||||
Integration with use cases will be added in future iterations.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
router = APIRouter(prefix="/web", tags=["web"])
|
||||
templates = Jinja2Templates(directory="app/presentation/templates")
|
||||
|
||||
|
||||
class MockPost:
|
||||
"""Mock post object for UI demonstration.
|
||||
|
||||
This class simulates a Post entity for template rendering
|
||||
before integration with actual use cases.
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier for the post.
|
||||
title: Post title value object.
|
||||
content: Post content value object.
|
||||
slug: URL-friendly slug.
|
||||
author_id: Identifier of the post author.
|
||||
published: Publication status flag.
|
||||
tags: List of tags associated with the post.
|
||||
created_at: Timestamp when the post was created.
|
||||
updated_at: Timestamp when the post was last updated.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
title: str,
|
||||
content: str,
|
||||
slug: str,
|
||||
author_id: str,
|
||||
published: bool,
|
||||
tags: list[str],
|
||||
created_at: datetime | None = None,
|
||||
) -> None:
|
||||
"""Initialize mock post with provided attributes.
|
||||
|
||||
Args:
|
||||
id: Unique identifier for the post.
|
||||
title: Post title string.
|
||||
content: Post content string.
|
||||
slug: URL-friendly slug string.
|
||||
author_id: Author identifier string.
|
||||
published: Whether the post is published.
|
||||
tags: List of tag strings.
|
||||
created_at: Optional creation timestamp, defaults to now.
|
||||
"""
|
||||
self.id = id
|
||||
self.title = MockValueObject(title)
|
||||
self.content = MockValueObject(content)
|
||||
self.slug = MockValueObject(slug)
|
||||
self.author_id = author_id
|
||||
self.published = published
|
||||
self.tags = tags
|
||||
self.created_at = created_at or datetime.now()
|
||||
self.updated_at = self.created_at
|
||||
|
||||
|
||||
class MockValueObject:
|
||||
"""Mock value object for simulating domain value objects.
|
||||
|
||||
Wraps a raw value to simulate the interface of domain
|
||||
value objects like Title, Content, and Slug.
|
||||
|
||||
Attributes:
|
||||
value: The wrapped string value.
|
||||
"""
|
||||
|
||||
def __init__(self, value: str) -> None:
|
||||
"""Initialize with a string value.
|
||||
|
||||
Args:
|
||||
value: The string value to wrap.
|
||||
"""
|
||||
self.value = value
|
||||
|
||||
|
||||
MOCK_POSTS = [
|
||||
MockPost(
|
||||
id=str(uuid4()),
|
||||
title="Getting Started with FastAPI",
|
||||
content="FastAPI is a modern, fast (high-performance), web framework for building APIs with Python 3.7+ based on standard Python type hints. It is designed to be easy to use while providing high performance.",
|
||||
slug="getting-started-with-fastapi",
|
||||
author_id="john_doe",
|
||||
published=True,
|
||||
tags=["python", "fastapi", "tutorial"],
|
||||
created_at=datetime(2026, 1, 15, 10, 30),
|
||||
),
|
||||
MockPost(
|
||||
id=str(uuid4()),
|
||||
title="Understanding DDD Architecture",
|
||||
content="Domain-Driven Design (DDD) is an approach to software development that centers the development on programming a domain model that has a rich understanding of the processes and rules of a domain. The term was coined by Eric Evans in his book of the same title.",
|
||||
slug="understanding-ddd-architecture",
|
||||
author_id="jane_smith",
|
||||
published=True,
|
||||
tags=["ddd", "architecture", "software-design"],
|
||||
created_at=datetime(2026, 1, 14, 14, 45),
|
||||
),
|
||||
MockPost(
|
||||
id=str(uuid4()),
|
||||
title="Draft Post Example",
|
||||
content="This is a draft post that hasn't been published yet. It demonstrates how unpublished posts appear in the UI.",
|
||||
slug="draft-post-example",
|
||||
author_id="john_doe",
|
||||
published=False,
|
||||
tags=["draft"],
|
||||
created_at=datetime(2026, 1, 13, 9, 0),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request) -> HTMLResponse:
|
||||
"""Render the home page with list of posts.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object for template context.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered posts list template.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"pages/index.html",
|
||||
{
|
||||
"posts": MOCK_POSTS,
|
||||
"active_page": "home",
|
||||
"current_page": 1,
|
||||
"has_prev": False,
|
||||
"has_next": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/posts", response_class=HTMLResponse)
|
||||
async def list_posts(request: Request) -> HTMLResponse:
|
||||
"""Render the posts listing page.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object for template context.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered posts list template.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"pages/index.html",
|
||||
{
|
||||
"posts": MOCK_POSTS,
|
||||
"active_page": "posts",
|
||||
"current_page": 1,
|
||||
"has_prev": False,
|
||||
"has_next": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/posts/new", response_class=HTMLResponse)
|
||||
async def new_post_form(request: Request) -> HTMLResponse:
|
||||
"""Render the new post creation form.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object for template context.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered post form template.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"pages/post_form.html",
|
||||
{
|
||||
"is_edit": False,
|
||||
"post": None,
|
||||
"active_page": "posts",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/posts/new", response_class=HTMLResponse)
|
||||
async def create_post(request: Request) -> HTMLResponse:
|
||||
"""Handle new post creation form submission.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object containing form data.
|
||||
|
||||
Returns:
|
||||
HTMLResponse redirecting to the home page.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"pages/index.html",
|
||||
{
|
||||
"posts": MOCK_POSTS,
|
||||
"active_page": "home",
|
||||
"current_page": 1,
|
||||
"has_prev": False,
|
||||
"has_next": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}", response_class=HTMLResponse)
|
||||
async def post_detail(request: Request, post_id: str) -> HTMLResponse:
|
||||
"""Render a single post detail page.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object for template context.
|
||||
post_id: The unique identifier of the post to display.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered post detail template.
|
||||
"""
|
||||
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), MOCK_POSTS[0])
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"pages/post_detail.html",
|
||||
{
|
||||
"post": post,
|
||||
"active_page": "posts",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}/edit", response_class=HTMLResponse)
|
||||
async def edit_post_form(request: Request, post_id: str) -> HTMLResponse:
|
||||
"""Render the post edit form.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object for template context.
|
||||
post_id: The unique identifier of the post to edit.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered post form template.
|
||||
"""
|
||||
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), MOCK_POSTS[0])
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"pages/post_form.html",
|
||||
{
|
||||
"is_edit": True,
|
||||
"post": post,
|
||||
"active_page": "posts",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/posts/{post_id}/edit", response_class=HTMLResponse)
|
||||
async def update_post(request: Request, post_id: str) -> HTMLResponse:
|
||||
"""Handle post update form submission.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object containing form data.
|
||||
post_id: The unique identifier of the post to update.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered post detail template.
|
||||
"""
|
||||
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), MOCK_POSTS[0])
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"pages/post_detail.html",
|
||||
{
|
||||
"post": post,
|
||||
"active_page": "posts",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/posts/{post_id}/delete", response_class=HTMLResponse)
|
||||
async def delete_post(request: Request, post_id: str) -> HTMLResponse:
|
||||
"""Handle post deletion.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object.
|
||||
post_id: The unique identifier of the post to delete.
|
||||
|
||||
Returns:
|
||||
HTMLResponse redirecting to the home page.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"pages/index.html",
|
||||
{
|
||||
"posts": MOCK_POSTS,
|
||||
"active_page": "home",
|
||||
"current_page": 1,
|
||||
"has_prev": False,
|
||||
"has_next": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/about", response_class=HTMLResponse)
|
||||
async def about(request: Request) -> HTMLResponse:
|
||||
"""Render the about page.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object for template context.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered about page template.
|
||||
"""
|
||||
return HTMLResponse(
|
||||
content="""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>About - Blog</title></head>
|
||||
<body>
|
||||
<h1>About</h1>
|
||||
<p>A modern blog built with FastAPI and DDD architecture.</p>
|
||||
<a href="/web/">Back to home</a>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
)
|
||||
Reference in New Issue
Block a user